【招生小程序】 新增# 主账号:工作台页面

master
kaeery 2025-03-04 16:44:59 +08:00
parent a4930290b5
commit 839a51027c
37 changed files with 4155 additions and 193 deletions

View File

@ -0,0 +1,101 @@
<template>
<w-card @click="handleDetail">
<template #title>
<view class="flex justify-between w-full py-[10rpx]">
<view class="flex">
<text>{{ item.studentName }}</text>
<view class="flex ml-[48rpx] gap-[4rpx] items-center">
<text class="text-primary">{{ item.phone }}</text>
<u-copy :content="item.phone">
<TIcon name="icon-copy" color="#0E66FB" />
</u-copy>
</view>
</view>
</view>
</template>
<template #content>
<view class="flex gap-[20rpx] mb-[16rpx]">
<text class="text-muted w-[128rpx]">基本情况</text>
<text class="flex-1">
{{ ellipsisDesc(item) }}
</text>
</view>
<view class="flex gap-[20rpx] mb-[16rpx]">
<text class="text-muted w-[128rpx]">跟进时间</text>
<text class="flex-1">{{ item.createTime }}</text>
</view>
<view class="flex gap-[20rpx] mb-[16rpx]">
<text class="text-muted w-[128rpx]">招生老师</text>
<text class="flex-1">{{ item.recruitTeacherName }}</text>
</view>
<view class="flex gap-[20rpx] mb-[16rpx]">
<text class="text-muted w-[128rpx]">领取时间</text>
<text class="flex-1">{{ item.createTime }}</text>
</view>
<view class="flex gap-[20rpx]">
<text class="text-muted w-[128rpx]">状态</text>
<text class="flex-1 text-orage">{{ parseStateText }}</text>
</view>
<view class="flex gap-[20rpx]">
<text class="text-muted w-[128rpx]">备注</text>
<view class="flex gap-[12rpx] flex-1">
<text class="text-error">{{ item.remark }}</text>
</view>
</view>
<view class="flex gap-[20rpx]">
<text class="text-muted w-[128rpx]">成交时间</text>
<text class="flex-1">{{ item.updateTime }}</text>
</view>
</template>
</w-card>
</template>
<script setup lang="ts">
import { computed, PropType } from 'vue'
import { converStatusEnum, stateEnum } from '@/enums'
export interface IClue {
studentName: string
phone: string
id: number
basicInformation: string
remark: string
isConversion: number
listSource: number //线
state: stateEnum //
situation: number //
createTime: string //
telemarketingTeacherName: string //
recruitTeacherName: string //
recruitTeacherId: number
telemarketingTeacherId: number
updateTime: string
}
const props = defineProps({
item: {
type: Object as PropType<IClue>,
default: () => ({})
}
})
const ellipsisDesc = computed(
() => (item: IClue) =>
item.basicInformation?.length >= 14
? item.basicInformation?.slice(0, 14) + '...'
: item.basicInformation
)
const stateMap: Record<stateEnum, string> = {
[stateEnum.ADD_RELATION]: '账号已添加',
[stateEnum.NO_EXIST]: '账号不存在',
[stateEnum.UN_PASS]: '账号未通过'
}
const parseStateText = computed(() => stateMap[props.item.state])
// 线
const handleDetail = () => {
const { item } = props
uni.navigateTo({
url: '/bundle/pages/clue/detail?id=' + item.id
})
}
</script>
<style scoped></style>

View File

@ -0,0 +1,95 @@
<template>
<view class="flex flex-col h-screen">
<view class="flex items-center justify-between">
<view class="flex-1 overflow-auto">
<u-tabs
:list="tabs"
:itemStyle="{ height: '44px', flex: 1 }"
:activeStyle="{ color: '#0E66FB' }"
lineWidth="32"
lineColor="#0E66FB"
:scrollable="isScrollable"
@change="handleChangeTab"
></u-tabs>
</view>
<view class="mr-[32rpx]">
<TIcon name="icon-filter" color="#999" />
</view>
</view>
<TSearch
v-model="queryParams.likeWork"
placeholder="搜索客户姓名/手机号码"
backgroundColor="#F5F5F5"
@on-input="searchChange"
/>
<view class="flex-1 px-[32rpx] pt-[24rpx] overflow-auto bg-[#FAFAFE]">
<z-paging
ref="paging"
v-model="dataList"
@query="queryList"
:fixed="false"
height="100%"
>
<clue-card
v-for="(item, index) in dataList"
:key="`${index} + 'unique'`"
:item="item"
/>
</z-paging>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { ref, shallowRef } from 'vue'
import clueCard from './components/clue-card.vue'
import { useZPaging } from '@/hooks/useZPaging'
import { apiTeleClueList } from '@/api/clue'
import { computed } from 'vue'
import { debounce } from 'lodash-es'
const queryParams = ref({
likeWork: ''
})
const dataList = ref([])
const { paging, queryList, refresh, changeApi, setParams } = useZPaging(
queryParams.value,
apiTeleClueList
)
const activeTab = ref(0)
const tabOptions: Record<string, any[]> = {
telesale: [
{ name: '全部', value: 0 },
{ name: '未领取', value: 1 },
{ name: '已领取', value: 2 },
{ name: '异常待处理', value: 3 }
],
recruitsale: [
{ name: '转化中', value: 0 },
{ name: '已添加', value: 1 },
{ name: '异常待处理', value: 2 },
{ name: '已成交', value: 3 },
{ name: '已战败', value: 4 }
]
}
const tabs = computed(() => tabOptions[type.value])
const isScrollable = computed(() => tabs.value?.length >= 5)
const handleChangeTab = item => {
activeTab.value = item.value
}
const searchChange = debounce(() => {
refresh(queryParams.value)
}, 300)
const type = ref('')
onLoad(option => {
if (option.id) {
uni.setNavigationBarTitle({
title: '张三'
})
type.value = option.type ?? ''
}
})
</script>
<style scoped></style>

View File

@ -96,18 +96,15 @@ import { AgreementEnum } from '@/enums/agreementEnums'
import { BACK_URL, ROLEINDEX } from '@/enums/cacheEnums'
import { ChannelEnum, LoginTypeEnum } from '@/enums/appEnums'
import logo from '@/static/images/logo.png'
import { useRoleData } from '@/hooks/useRoleData'
import { useTabBarStore } from '@/stores/tabbar'
import { setNextRoute } from '@/hooks/useRoleData'
import { getAllDict } from '@/hooks/useDictOptions'
const tabBarStore = useTabBarStore()
const userStore = useUserStore()
const appStore = useAppStore()
const loginData = ref()
const checked = ref<string[]>([])
const isCheckAgreement = ref()
const wxLoginCode = ref()
const { roles } = useRoleData()
const isChecked = computed(() => unref(checked).length > 0)
const form = ref({
@ -237,27 +234,22 @@ async function loginHandle(data: any) {
uni.hideLoading()
// appStore.setFirstLogin(true)
const { userInfo } = userStore
if (userInfo.roles?.length > 1) {
uni.redirectTo({
url: '/bundle/pages/select-role/index'
})
} else {
// /
if (userInfo.postIds == 0) {
cache.set(ROLEINDEX, 2)
setNextRoute()
} else {
if (userInfo.roles?.length > 1) {
uni.redirectTo({
url: '/bundle/pages/select-role/index'
})
} else {
setNextRoute()
}
}
cache.remove(BACK_URL)
}
const setNextRoute = () => {
const ind = cache.get(ROLEINDEX) || 0
const { userInfo } = userStore
const obj = roles.find(role => {
return role.ids.includes(userInfo.roles[ind].id)
})
tabBarStore.setTabBarList(obj?.tabBarList)
uni.reLaunch({
url: obj?.tabBarList[0].path || ''
})
}
/**点击空白处 */
const handleClickEmpty = e => {
if (e.target.dataset.name) {

View File

@ -0,0 +1,87 @@
<template>
<view>
<u-tabs
:list="tabs"
:scrollable="false"
:activeStyle="{
color: '#303133',
fontWeight: 'bold',
transform: 'scale(1.05)'
}"
:inactiveStyle="{
color: '#606266',
transform: 'scale(1)'
}"
itemStyle="padding-left: 15px; padding-right: 15px; height: 34px;"
lineWidth="32"
lineColor="#0E66FB"
@change="handleChangeTab"
></u-tabs>
<TTable :columns="columns" :data="data">
<template #index="scope">
<text class="px-[16rpx] py-[6rpx] rounded-[4px]" :style="rankStyle(scope.row)">
{{ scope.row }}
</text>
</template>
</TTable>
</view>
</template>
<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
const tabs = shallowRef([
{ name: '电销业绩排行榜', value: 1 },
{ name: '招生业绩排行榜', value: 2 }
])
const activeTab = ref(1)
const totalLabel = computed(() => (activeTab.value == 1 ? '意向' : '成交'))
const handleChangeTab = item => {
activeTab.value = item.value
generateColumns()
}
const generateColumns = () => {
return [
{ name: 'index', label: '排名', slot: true },
{ name: 'name', label: '姓名' },
{ name: 'total', label: totalLabel.value + '客户数', width: 160, align: 'right' }
]
}
const columns = ref(generateColumns())
const data = [
{
index: 1,
name: '王小虎1789789789789789',
total: 100
},
{
index: 2,
name: '王小虎2',
total: 20
},
{
index: 3,
name: '王小虎2',
total: 20
},
{
index: 4,
name: '王小虎2',
total: 20
}
]
const rankStyle = computed(() => row => {
const styleMap: Record<number, string> = {
'1': '#FCAE3C',
'2': '#BCC1D8',
'3': '#EEB286'
}
return {
color: row >= 4 ? '#3d3d3d' : '#fff',
background: styleMap[row]
}
})
</script>
<style scoped></style>

View File

@ -0,0 +1,164 @@
# 2.2.2
修复
1. 修复`picker`值选定残留问题
# 2.2.1
优化
1. 新增`getMenuList`方法获取菜单数据【示例6】
2. 新增`getMenuIndex`方法,获取指定`prop`的菜单索引位置
3. 优化示例使用方法
4. 修复互斥、联动的值处理问题
5. 更新了说明文档
# 2.2.0
推荐更新
1. 新增更多的示例,示例更全面丰富
2. 新增`openMenuItemPopup`方法打开指定菜单弹窗【示例8】
3. 新增`closeMenuPopup`方法关闭菜单弹窗【示例5】
4. 新增`getMenuValue`方法获取菜单的值【示例8】
5. 新增`updateMenu`方法更新指定菜单项【示例7】
6. 新增`setMenuLoading`方法让某个菜单项处于加载中状态异步数据有用【示例7】
7. 修复`fixedTopValue`及顶部样式错误【示例2】
8. 新增`syncDataKey`支持异步数据嵌套回调【示例7】
9. 新增异步数据加载状态【示例7】
10. 修复日期快捷获取上个月时间的错误
11. 修复手动关闭时存在的点击残留
12. 优化菜单项联动、互斥选择
13. 优化异步数据阻塞菜单回显问题
14. 修复数据回调时,会重新渲染菜单问题
15. `@close`参数2支持返回当前菜单列表(Array)
16. `@confirm`参数2支持返回当前菜单已选内容(Object)
17. 菜单项`daterange`支持`showQuick`控制是否显示日期快选
18. 移除`menuActiveText`,用处不大
19. 更新了说明文档
# 2.1.1
优化
1. 优化图标字体命名
# 2.1.0
推荐更新
1. 现阶段由于兼容性问题,移除动态插槽
2. 新增五个拓展插槽,`data`类型为 `slot1`/`slot2`/`slot3`/`slot4`/`slot5`
3. 修复倒三角点击蒙层没有复原
4. 新增更多演示示例
5. 优化非固定页面顶部的效果
6. 类型`cell`(下拉列表)数据项新增 `disabled` 用来禁用部分不可用选项
7. 修复小程序对`v-show`、主题色的兼容问题
8. 优化对 APP 的兼容
# 2.0.9
修复
1. 修复初始化数据时可能存在的选项高亮问题
# 2.0.8
优化
1. 优化拷贝函数引起的 App 兼容问题
# 2.0.7
优化
1. 优化模块图标支持主题换色
2. 新增`fixedTopValue`,用于优化异形屏导致搜索框被挡住问题,具体请参考示例项目
# 2.0.6
优化
1. 优化三角图标支持主题换色
# 2.0.5
修复
1. 修复 `picker` 大量数据时未能滚动问题
# 2.0.4
修复
1. 修复弹窗后点击 `click`、`sort` 类型未能收起弹窗
# 2.0.3
优化
1. 移除 scss 的 @use 用法,以修复可能会导致部分用户的 lint 错误
# 2.0.2
优化
1. 优化菜单样式
# 2.0.1
新增
1. 下拉列表`cell`、级联`picker`新增选中图标`showIcon`
# 2.0.0
移除 TS
1. 移除了 TS 写法,现在是纯粹 JS 版本的 Vue3 版本
2. 进一步完善使用文档及示例
# 1.2.1
新增功能
1. 新增顶部搜索,当 type 为 `slot` 时,可在弹窗内容自定义插槽
2. 新增自定插槽,当 type 为 `search` 时,头部显示搜索框
3. 优化界面样式
# 1.1.2
优化异步菜单项数据
1. 菜单项新增 `syncDataFn` 函数字段,返回异步菜单项数据内容
# 1.1.1
优化固定弹窗问题
1. `da-dropdown`新增 `bgColor` 字段,当固定在顶部时,需要填写背景颜色,默认`#fff`
2. 优化弹窗时底部滑动问题
# 1.1.0
优化数据问题
### 优化
1. `da-dropdown`新增 `prop` 字段,通过 prop 的唯一性来区分已选的数据
2. `da-dropdown`新增 `fixedTop` 字段,为 true 时将会固定在头部
3. 优化说明文档
# 1.0.0
初始版本 1.0.0,基于 Typescript+Scss 进行开发,基本完善相关各大平台的小程序兼容问题
### 新增
1. 下拉列表(单选)
2. 点击高亮
3. 点击排序
4. 下拉筛选(单选按钮、多选按钮、滑动选择器)
5. 级联筛选(单选)
6. 日期筛选(日期快选、日期区间选择)

View File

@ -0,0 +1,176 @@
<template>
<view class="da-dropdown-cell">
<view
class="da-dropdown-cell-item"
:class="[item.checked ? 'is-actived' : '', item.disabled ? 'is-disabled' : '']"
v-for="(item, index) in cellOptions"
:key="index"
@click="handleSelect(item)"
>
<text class="da-dropdown-cell-item--label">{{ item.label }}</text>
<text class="da-dropdown-cell-item--suffix">{{ item.suffix }}</text>
<text class="da-dropdown-cell-item--check" v-if="item.checked && showIcon" />
</view>
</view>
</template>
<script>
import { defineComponent, watch, ref } from 'vue'
import { deepClone } from '../utils'
export default defineComponent({
props: {
dropdownItem: {
type: Object,
default: null
},
dropdownIndex: {
type: Number
}
},
emits: ['success'],
setup(props, { emit }) {
const cellOptions = ref([])
const showIcon = ref(false)
function initData(options, value) {
const list = deepClone(options)
for (let i = 0; i < list.length; i++) {
const item = list[i]
if (item.value === value) {
item.checked = true
break
}
}
cellOptions.value = list
}
function handleSelect(item) {
if (item.disabled) {
return
}
if (props.dropdownItem?.prop) {
const res = { [props.dropdownItem.prop]: item.value }
emit('success', res, item, props.dropdownIndex)
} else {
console.error(`菜单项${props.dropdownItem.title}未定义prop返回内容失败`)
}
}
watch(
() => props.dropdownItem,
v => {
if (v?.options?.length) {
initData(v.options, v.value)
} else {
cellOptions.value = []
}
showIcon.value = v?.showIcon || false
},
{ immediate: true, deep: true }
)
return {
cellOptions,
showIcon,
handleSelect
}
}
})
</script>
<style lang="scss" scoped>
//
.da-dropdown-cell {
--cell-height: 80rpx;
width: 100%;
max-height: 60vh;
overflow: hidden auto;
&-item {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: var(--cell-height);
padding: 0 24rpx;
overflow: hidden;
font-size: 28rpx;
color: var(--dropdown-text-color);
white-space: nowrap;
border-bottom: 1rpx solid #dedede;
&:last-child {
border-bottom: none;
}
&--label {
flex-grow: 1;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
// #ifdef H5
:deep(> span) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// #endif
}
&--suffix {
flex-grow: 1;
margin-left: 10px;
overflow: hidden;
font-size: 24rpx;
color: #999;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
// #ifdef H5
:deep(> span) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// #endif
}
&--check {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--cell-height);
height: var(--cell-height);
&::after {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'da-dropdown-iconfont' !important;
font-size: calc(var(--cell-height) / 3 * 2);
font-style: normal;
content: '\e736';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
&.is-actived {
color: var(--dropdown-theme-color);
}
&.is-disabled {
color: #aaa;
background: #efefef;
}
}
}
</style>

View File

@ -0,0 +1,212 @@
<template>
<view class="da-dropdown-daterange-box">
<view class="da-dropdown-daterange">
<view class="da-dropdown-daterange--date" :class="daterange.start ? 'is-actived' : ''">
<picker mode="date" :value="daterange.start" @change="handleStartDate">
{{ daterange.start || '请选择日期' }}
</picker>
</view>
<view class="da-dropdown-daterange--separate"></view>
<view class="da-dropdown-daterange--date" :class="daterange.end ? 'is-actived' : ''">
<picker
mode="date"
:value="daterange.end"
:disabled="!daterange.start"
:start="daterange.start"
@change="handleEndDate"
>
{{ daterange.end || '请选择日期' }}
</picker>
</view>
</view>
<view class="da-dropdown-daterange-tags" v-if="dropdownItem.showQuick">
<block v-for="(tag, tagi) in dateTagList" :key="tagi">
<view
class="da-dropdown-tag"
:class="datetag === tag.value ? 'is-actived' : ''"
@click="handleTagDate(tag.value)"
>
<text class="da-dropdown-tag--text">{{ tag.label }}</text>
</view>
</block>
</view>
<PartDropdownFooter
:resetText="dropdownItem.resetText"
:confirmText="dropdownItem.confirmText"
@reset="handleReset()"
@confirm="handleConfirm()"
></PartDropdownFooter>
</view>
</template>
<script>
import { defineComponent, ref, watch } from 'vue'
import { deepClone, getRangeDate } from '../utils'
import PartDropdownFooter from './part-dropdown-footer.vue'
export default defineComponent({
components: { PartDropdownFooter },
props: {
dropdownItem: {
type: Object,
default: null
},
dropdownIndex: {
type: Number
}
},
emits: ['success'],
setup(props, { emit }) {
const daterange = ref(null)
const datetag = ref('')
const dateTagList = ref([
{ value: '-7', label: '本周' },
{ value: '-14', label: '上周' },
{ value: '-30', label: '本月' },
{ value: '-60', label: '上月' },
// { value: '-1', label: '' },
{ value: '7', label: '近7天' },
{ value: '15', label: '近15天' },
{ value: '30', label: '近30天' }
])
function initData(dropdownItem, clearValue = false) {
const item = deepClone(dropdownItem || null)
if (clearValue === true) {
daterange.value = {
start: '',
end: ''
}
datetag.value = ''
} else {
daterange.value = {
start: item.value?.start || '',
end: item.value?.end || ''
}
}
}
function handleStartDate(item) {
daterange.value.start = item.detail.value
daterange.value.end = ''
datetag.value = ''
}
function handleEndDate(item) {
if (!daterange.value?.start) {
return
}
daterange.value.end = item.detail.value
datetag.value = ''
}
function handleTagDate(code) {
daterange.value = getRangeDate(code)
datetag.value = code
}
function handleReset() {
initData(props.dropdownItem, true)
}
function handleConfirm() {
if (props.dropdownItem?.prop) {
const res = { [props.dropdownItem.prop]: deepClone(daterange.value) }
emit('success', res, daterange.value, props.dropdownIndex)
} else {
console.error(`菜单项${props.dropdownItem.title}未定义prop返回内容失败`)
}
}
watch(
() => props.dropdownItem,
v => {
initData(v)
},
{ immediate: true }
)
return {
daterange,
datetag,
dateTagList,
handleStartDate,
handleEndDate,
handleTagDate,
handleReset,
handleConfirm
}
}
})
</script>
<style lang="scss" scoped>
//
.da-dropdown-daterange {
display: flex;
align-items: center;
margin: 24rpx;
background-color: #f5f5f5;
border-radius: 999rpx;
&--date {
flex-grow: 1;
height: 66rpx;
padding: 0 24rpx;
font-size: 26rpx;
line-height: 66rpx;
color: var(--dropdown-text-color);
text-align: center;
border-radius: 4rpx;
&.is-actived {
color: var(--dropdown-theme-color);
}
}
&--separate {
flex-shrink: 0;
padding: 0 20rpx;
}
&-tags {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
padding: 0 24rpx;
}
}
.da-dropdown-tag {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 40rpx;
margin-right: 20rpx;
margin-bottom: 20rpx;
overflow: hidden;
font-size: 28rpx;
color: var(--dropdown-text-color);
background-color: #f5f5f5;
border-radius: 999rpx;
&--text {
position: relative;
z-index: 1;
}
&.is-actived {
color: var(--dropdown-theme-color);
background-color: #fff;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
content: '';
background-color: var(--dropdown-theme-color);
opacity: 0.05;
}
}
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<view class="da-dropdown-filter">
<view class="da-dropdown-filter-box" v-for="(item, index) in filterList" :key="index">
<view class="da-dropdown-filter--title">{{ item.title }}</view>
<view class="da-dropdown-filter-content">
<!-- 单选类型 -->
<block v-if="item.type === 'radio'">
<view
v-for="(opt, optIdx) in item.options"
class="da-dropdown-filter-item da-dropdown-tag"
:class="item.value === opt.value ? 'is-actived' : ''"
:key="optIdx"
@click="handleRadioChange(item, opt, optIdx, index)"
>
<text class="da-dropdown-tag--text">{{ opt.label }}</text>
</view>
</block>
<!-- 多选类型 -->
<block v-else-if="item.type === 'checkbox'">
<view
v-for="(opt, optIdx) in item.options"
class="da-dropdown-filter-item da-dropdown-tag"
:class="opt.isActived ? 'is-actived' : ''"
:key="optIdx"
@click="handleCheckboxChange(item, opt, optIdx, index)"
>
<text class="da-dropdown-tag--text">{{ opt.label }}</text>
</view>
</block>
<!-- 滑块类型 -->
<block v-else-if="item.type === 'slider'">
<slider
style="width: 100%"
:value="item.value"
:min="item.componentProps.min || 0"
:max="item.componentProps.max || 100"
:step="item.componentProps.step || 1"
:activeColor="item.componentProps.activeColor"
:show-value="item.componentProps.showValue"
@change="e => handleSliderChange(e, item, index)"
/>
</block>
</view>
</view>
<PartDropdownFooter
:resetText="dropdownItem.resetText"
:confirmText="dropdownItem.confirmText"
@reset="handleReset()"
@confirm="handleConfirm()"
></PartDropdownFooter>
</view>
</template>
<script>
import { defineComponent, ref, watch } from 'vue'
import { deepClone } from '../utils'
import PartDropdownFooter from './part-dropdown-footer.vue'
export default defineComponent({
components: { PartDropdownFooter },
props: {
dropdownItem: {
type: Object,
default: null
},
dropdownIndex: {
type: Number
}
},
emits: ['success', 'change'],
setup(props, { emit }) {
const filterList = ref(null)
function initData(dropdownItem, clearValue = false) {
const { options = [], value = {} } = dropdownItem
if (options?.length) {
const list = deepClone(options)
for (let i = 0; i < list.length; i++) {
const k = list[i]
if (clearValue !== true && (value[k.prop] || value[k.prop] === 0)) {
k.value = value[k.prop]
}
//
if (k.type === 'checkbox' && k.value?.length) {
if (k.options?.length) {
k.options.forEach(x => {
x.isActived = k.value?.includes(x.value)
})
}
}
}
filterList.value = list
} else {
filterList.value = []
}
}
function handleRadioChange(item, opt, _optIdx, _index) {
item.value = opt.value
}
function handleCheckboxChange(item, opt, _optIdx, _index) {
if (opt.isActived) {
opt.isActived = false
if (item.value?.length) {
const idx = item.value.findIndex(k => k === opt.value)
item.value.splice(idx, 1)
} else {
item.value = []
}
} else {
if (item.value?.length) {
item.value.push(opt.value)
} else {
item.value = [opt.value]
}
opt.isActived = true
}
}
function handleSliderChange(event, item, _index) {
const v = event.detail.value
item.value = v
}
function handleReset() {
initData(props.dropdownItem || [], true)
}
function handleConfirm() {
const list = deepClone(filterList.value)
if (props.dropdownItem?.prop) {
const obj = {}
for (let i = 0; i < list.length; i++) {
const k = list[i]
if (k.value || k.value === 0) {
obj[k.prop] = k.value
}
}
const res = { [props.dropdownItem.prop]: obj }
emit('success', res, obj, props.dropdownIndex)
} else {
console.error(`菜单项${props.dropdownItem.title}未定义prop返回内容失败`)
}
}
watch(
() => props.dropdownItem,
v => {
initData(v || null)
},
{ immediate: true }
)
return {
filterList,
handleRadioChange,
handleCheckboxChange,
handleSliderChange,
handleReset,
handleConfirm
}
}
})
</script>
<style lang="scss" scoped>
//
.da-dropdown-filter {
&-box {
padding: 0 24rpx;
line-height: 1;
}
&--title {
flex-shrink: 0;
padding: 20rpx 0;
font-size: 26rpx;
color: var(--dropdown-text-color);
white-space: nowrap;
}
&-content {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
}
}
.da-dropdown-tag {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 40rpx;
margin-right: 20rpx;
margin-bottom: 20rpx;
overflow: hidden;
font-size: 28rpx;
color: var(--dropdown-text-color);
background-color: #f5f5f5;
border-radius: 999rpx;
&--text {
position: relative;
z-index: 1;
}
&.is-actived {
color: var(--dropdown-theme-color);
background-color: #fff;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
content: '';
background-color: var(--dropdown-theme-color);
opacity: 0.05;
}
}
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<view class="da-dropdown-footer">
<view class="da-dropdown-footer--reset" @click="handleReset()">
{{ resetText || '重置' }}
</view>
<view class="da-dropdown-footer--confirm" @click="handleConfirm()">
{{ confirmText || '确定' }}
</view>
</view>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'PartDropdownFooter',
props: {
resetText: {
type: String,
default: '重置'
},
confirmText: {
type: String,
default: '确定'
}
},
emits: ['confirm', 'reset'],
setup(_, { emit }) {
function handleReset() {
emit('reset')
}
function handleConfirm() {
emit('confirm')
}
return {
handleReset,
handleConfirm
}
}
})
</script>
<style lang="scss" scoped>
.da-dropdown-footer {
display: flex;
align-items: center;
padding: 24rpx;
margin-top: 20rpx;
&--reset,
&--confirm {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 66rpx;
font-size: 28rpx;
color: #555;
background-color: #fff;
border: 2rpx solid #ccc;
border-radius: 66rpx;
}
&--confirm {
margin-left: 24rpx;
color: #fff;
background-color: var(--dropdown-theme-color);
border-color: var(--dropdown-theme-color);
}
&--reset:hover,
&--confirm:hover {
opacity: 0.8;
}
}
</style>

View File

@ -0,0 +1,222 @@
<template>
<view class="da-dropdown-picker" v-if="viewCol && viewCol.length">
<view class="da-dropdown-picker-inner" v-for="(vc, vci) in viewCol" :key="vci">
<scroll-view class="da-dropdown-picker-view" scroll-y>
<view
class="da-dropdown-picker-item"
:class="vr.checked ? 'is-actived' : ''"
v-for="(vr, vri) in viewRow[vci]"
:key="vri"
@click="handleSelect(vr, vci, vri)"
>
<text class="da-dropdown-picker-item--name">{{ vr.label }}</text>
<text
class="da-dropdown-picker-item--icon"
v-if="vr.children && vr.children.length"
></text>
<text
class="da-dropdown-picker-item--check"
v-if="vr.checked && (!vr.children || vr.children.length === 0)"
/>
</view>
</scroll-view>
</view>
</view>
</template>
<script>
import { defineComponent, ref, watch } from 'vue'
import { deepClone } from '../utils'
export default defineComponent({
props: {
dropdownItem: {
type: Object,
default: null
},
dropdownIndex: {
type: Number
}
},
emits: ['success'],
setup(props, { emit }) {
const viewCol = ref([])
const viewRow = ref([])
function checkData(selected, list) {
for (let i = 0; i < list.length; i++) {
const k = list[i]
for (let j = 0; j < selected.length; j++) {
const x = selected[j]
if (k.value === x) {
k.checked = true
viewCol.value.push(k.value)
viewRow.value.push(list)
if (k.children?.length) {
checkData(selected, k.children)
}
break
}
}
}
}
function initData(item) {
const list = deepClone(item?.options || [])
if (list?.length) {
if (item.value?.length) {
viewCol.value = []
viewRow.value = []
checkData(item.value, list)
} else {
viewCol.value.push('tmpValue')
viewRow.value.push(list)
}
} else {
viewCol.value = []
viewRow.value = []
}
}
function handleSelect(item, colIndex, _rowIndex) {
let lastItem = false
viewCol.value.splice(colIndex)
viewCol.value[colIndex] = item.value
if (viewRow.value[colIndex]?.length) {
viewRow.value[colIndex].forEach(k => {
k.checked = false
})
}
item.checked = true
const list = item?.children || null
if (list?.length) {
viewCol.value[colIndex + 1] = 'tmpValue'
viewRow.value[colIndex + 1] = list
lastItem = false
} else {
console.warn('最后一项', item)
lastItem = true
}
try {
if (viewRow.value[colIndex + 1]?.length) {
viewRow.value[colIndex + 1].forEach(k => {
k.checked = false
})
}
} catch (e) {
console.warn('try clean row data', e)
// --
}
if (lastItem) {
if (props.dropdownItem?.prop) {
const res = { [props.dropdownItem.prop]: deepClone(viewCol.value) }
// emit('success', res, viewCol.value, props.dropdownIndex)
} else {
console.error(`菜单项${props.dropdownItem.title}未定义prop返回内容失败`)
}
}
}
watch(
() => props.dropdownItem,
v => {
initData(v)
},
{ immediate: true }
)
return {
viewCol,
viewRow,
handleSelect
}
}
})
</script>
<style lang="scss" scoped>
.da-dropdown-picker {
display: flex;
width: 100%;
max-height: 60vh;
overflow: hidden;
line-height: 1;
&-inner {
flex-grow: 1;
}
&-view {
display: flex;
/* #ifdef MP-ALIPAY */
flex-direction: column;
flex-wrap: wrap;
/* #endif */
width: 100%;
height: 100%;
+ .da-dropdown-picker-view {
border-left: 1px solid #eee;
}
}
&-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
font-size: 24rpx;
color: var(--dropdown-text-color);
text-align: left;
&--icon {
width: 24rpx;
height: 24rpx;
&::after {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'da-dropdown-iconfont' !important;
font-size: 24rpx;
font-style: normal;
content: '\e643';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
&--check {
flex-shrink: 0;
width: 24rpx;
height: 24rpx;
&::after {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'da-dropdown-iconfont' !important;
font-size: 24rpx;
font-style: normal;
content: '\e696';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
&:hover {
background: #eee;
}
&.is-actived {
color: var(--dropdown-theme-color);
}
}
}
</style>

View File

@ -0,0 +1,946 @@
<template>
<view
class="da-dropdown"
:class="{ 'is-fixed': fixedTop, 'has-search': hasSearch }"
:style="dropdownStyle"
>
<!-- 搜索 -->
<view class="da-dropdown-search" v-if="hasSearch" @touchmove.stop.prevent="handleMove">
<input
class="da-dropdown-search-input"
:value="searchItem.value"
@input="handleSearchChange"
:placeholder="searchItem.placeholder || '请输入'"
@confirm="handleSearch"
confirm-type="search"
/>
<button class="da-dropdown-search-btn" @click="handleSearch"></button>
</view>
<!-- 菜单 -->
<view class="da-dropdown-menu" @touchmove.stop.prevent="handleMove">
<view
class="da-dropdown-menu-item"
:class="{ 'is-hidden': item.isHidden === 'true' }"
v-for="(item, index) in menuList"
:key="index"
@click="handleMenuClick(index, item)"
>
<text
class="da-dropdown-menu-item--text"
:class="item.isActived ? 'is-actived' : ''"
>
{{ item.title }}
</text>
<view class="da-dropdown-menu-item--icon" v-if="item.showArrow">
<text v-if="item.isLoading" class="is--loading"></text>
<text v-else-if="item.isClick" class="is--arrup"></text>
<text v-else class="is--arrdown"></text>
</view>
<view
class="da-dropdown-menu-item--sort"
v-else-if="item.showSort"
:class="'is--' + item.value"
></view>
</view>
</view>
<!-- 弹出 -->
<view class="da-dropdown-content" :class="{ 'is-show': isShow, 'is-visible': isVisible }">
<view
class="da-dropdown-content-popup"
:class="isShow ? 'is-show' : ''"
style="height: 500px"
>
<view class="da-dropdown-popup-box" v-for="(item, index) in menuList" :key="index">
<!-- 下拉列表 -->
<DropdownCell
v-if="item.type === 'cell' && index === currentIndex"
:dropdownItem="item"
:dropdownIndex="index"
@success="handleCellSelect"
></DropdownCell>
<!-- 多条件筛选 -->
<DropdownFilter
v-if="item.type === 'filter' && index === currentIndex"
:dropdownItem="item"
:dropdownIndex="index"
@success="handleFilterConfirm"
></DropdownFilter>
<!-- 级联选择 -->
<DropdownPicker
v-if="item.type === 'picker' && index === currentIndex"
:dropdownItem="item"
:dropdownIndex="index"
@success="handlePickerConfirm"
/>
<!-- 日期范围 -->
<DropdownDaterange
v-if="item.type === 'daterange' && index === currentIndex"
:dropdownItem="item"
:dropdownIndex="index"
@success="handleDaterangeConfirm"
/>
<!-- 弹窗插槽拓展X5 -->
<template v-if="item.type === 'slot1' && index === currentIndex">
<slot name="slot1" :item="item" :index="index"></slot>
</template>
<template v-if="item.type === 'slot2' && index === currentIndex">
<slot name="slot2" :item="item" :index="index"></slot>
</template>
<template v-if="item.type === 'slot3' && index === currentIndex">
<slot name="slot3" :item="item" :index="index"></slot>
</template>
<template v-if="item.type === 'slot4' && index === currentIndex">
<slot name="slot4" :item="item" :index="index"></slot>
</template>
<template v-if="item.type === 'slot5' && index === currentIndex">
<slot name="slot5" :item="item" :index="index"></slot>
</template>
</view>
</view>
<view
class="da-dropdown-content-mask"
v-if="fixedTop"
@tap="handlePopupMask"
@touchmove.stop.prevent="handleMove"
/>
</view>
<view class="da-dropdown--blank" v-if="fixedTop"></view>
</view>
</template>
<script>
import { defineComponent, ref, computed, onMounted } from 'vue'
import { deepClone, menuInitOpts, getValueByKey, checkDataField } from './utils'
import DropdownPicker from './components/picker.vue'
import DropdownCell from './components/cell.vue'
import DropdownFilter from './components/filter.vue'
import DropdownDaterange from './components/daterange.vue'
export default defineComponent({
components: { DropdownPicker, DropdownCell, DropdownFilter, DropdownDaterange },
props: {
/**
* 导航菜单数据
*/
dropdownMenu: {
type: Array,
default: () => []
},
/**
* 激活颜色
*/
themeColor: {
type: String,
default: '#007aff'
},
/**
* 常规颜色
*/
textColor: {
type: String,
default: '#333333'
},
/**
* 背景颜色当固定在顶部时此为必填
*/
bgColor: {
type: String,
default: '#ffffff'
},
/**
* 是否固定在顶部
*/
fixedTop: {
type: Boolean,
default: false
},
/**
* 固定在头部时的位置单位px
* 如果页面定义了 "navigationStyle": "custom" 因此固定头部时需要额外获取状态栏高度以免被异形屏头部覆盖
*/
fixedTopValue: {
type: Number,
default: 0
},
/**
* 弹窗过渡时间
*/
duration: {
type: [Number, String],
default: 300
}
},
emits: ['open', 'close', 'confirm'],
setup(props, { emit }) {
const currentIndex = ref(-1)
const isVisible = ref(false)
const isShow = ref(false)
const menuList = ref([])
const hasSearch = ref(false)
const searchItem = ref(null)
//
const dropdownStyle = computed(() => {
return `
--dropdown-theme-color: ${props.themeColor};
--dropdown-text-color: ${props.textColor};
--dropdown-background-color: ${props.bgColor};
--dropdown-popup-duration: ${props.duration / 1000}s;
--dropdown-fixed-top: ${props.fixedTopValue || 0}px;
`
})
/**
* 初始化数据
*/
async function initData() {
const newMenu = deepClone(props.dropdownMenu || [])
const allItem = { label: '不限', value: '-9999' }
if (!newMenu || newMenu.length === 0) {
menuList.value = []
return
}
for (let i = 0; i < newMenu.length; i++) {
let item = newMenu[i]
if (item?.type) {
item = { ...(menuInitOpts[newMenu[i]['type']] || {}), ...item }
}
//
if (typeof item.syncDataFn === 'function') {
item.isLoading = true
item.syncDataFn(item, i)
.then(res => {
menuList.value[i].options = checkDataField(
item.syncDataKey ? getValueByKey(res, item.syncDataKey) : res,
item.field
)
//
if (this.menuList[i].showAll) {
if (
this.menuList[i].options.findIndex(
k => k.value === allItem.value
) === -1
) {
this.menuList[i].options.unshift(allItem)
}
}
menuList.value[i].isLoading = false
})
.catch(() => {
menuList.value[i].isLoading = false
})
}
if (item.options?.length) {
//
item.options = checkDataField(item.options, item.field)
//
if (item.showAll) {
if (item.options.findIndex(k => k.value === allItem.value) === -1) {
item.options.unshift(allItem)
}
}
}
//
if (typeof item.value !== 'undefined') {
switch (item.type) {
case 'cell':
for (let x = 0; x < item.options.length; x++) {
const k = item.options[x]
if (k.value === item.value) {
item.isActived = true
break
}
}
break
case 'click':
item.isActived = item.value === true
break
case 'sort':
item.isActived = item.value === 'asc' || item.value === 'desc'
break
case 'filter':
item.isActived = JSON.stringify(item.value || {}) !== '{}'
break
case 'picker':
item.isActived = item.value?.length
break
case 'daterange':
item.isActived = item.value?.start && item.value?.end
break
case 'slot':
item.isActived = !!item.value
break
default:
break
}
} else {
item.isActived = false
}
//
if (!hasSearch.value && item.type === 'search') {
item.isHidden = 'true'
searchItem.value = item
hasSearch.value = true
}
newMenu[i] = item
}
menuList.value = newMenu
}
/**
* 更新数据
* @param prop
* @param value
* @param key
*/
function updateMenu(prop, value, key) {
if (!key) {
console.error('updateMenu 错误key不存在')
return
}
const idx = getMenuIndex(prop)
menuList.value[idx][key] =
key === 'options' ? checkDataField(value, menuList.value[idx].field || null) : value
//
if (key === 'value' && !value && value !== 0) {
menuList.value[idx].isActived = false
}
}
/**
* 更新数据
* @param prop
* @param state
*/
function setMenuLoading(prop, state) {
const idx = getMenuIndex(prop)
menuList.value[idx].isLoading = state
}
/**
* 获取菜单项位置
* @param prop
*/
function getMenuIndex(prop) {
return menuList.value.findIndex(k => k.prop === prop)
}
/**
* 获取菜单数据
*/
function getMenuList() {
return menuList.value
}
/**
* 初始化获取系统信息
*/
function initDomInfo() {}
/**
* 打开弹窗
* @param index 当前激活索引
*/
function openMenuItemPopup(index) {
console.log('openMenuItemPopup')
isShow.value = true
isVisible.value = true
currentIndex.value = index
menuList.value[index].isClick = true
emit('open', currentIndex.value)
}
/**
* 关闭弹窗
*/
function closeMenuPopup() {
clearClickState()
isShow.value = false
//
setTimeout(() => {
isVisible.value = false
clearIndex()
}, props.duration)
emit('close', currentIndex.value, menuList.value)
}
/**
* 点击蒙层
*/
function handlePopupMask() {
closeMenuPopup()
}
/**
* 清除点击状态
*/
function clearClickState() {
if (menuList.value?.length) {
menuList.value.forEach(k => {
k.isClick = false
})
}
}
/**
* 清理滚动
*/
function handleMove() {
return false
}
/**
* 关闭弹窗
*/
function clearIndex() {
currentIndex.value = -1
}
/**
* 点击菜单项
*/
function handleMenuClick(index, item) {
if (item.isLoading) return
const dropdownMenu = menuList.value
const menuItem = dropdownMenu[index]
dropdownMenu.forEach(k => {
k.isClick = false
})
if (menuItem.type === 'click') {
return handleItemClick(menuItem, index)
}
if (menuItem.type === 'sort') {
return handleItemSort(menuItem, index)
}
if (index === currentIndex.value) {
item.isClick = false
closeMenuPopup()
return
}
item.isClick = true
openMenuItemPopup(index)
}
/**
* 获取菜单值
*/
function getMenuValue() {
const obj = {}
menuList.value.forEach(k => {
obj[k.prop] = k.value
})
return obj
}
/**
* 搜索输入
*/
function handleSearchChange(e) {
searchItem.value.value = e?.detail?.value
}
/**
* 确定搜索
*/
function handleSearch() {
if (searchItem.value?.prop) {
const res = { [searchItem.value.prop]: searchItem.value.value }
emit('confirm', res, getMenuValue())
} else {
console.error(`菜单项${searchItem.value.title}未定义prop返回内容失败`)
}
}
/**
* 菜单项-下拉列表回调
* @param callbackData 操作返回的数据
* @param cellItem 下拉列表项数据
* @param index 菜单索引
*/
function handleCellSelect(callbackData, cellItem, index) {
const dropdownMenu = menuList.value
const item = dropdownMenu[index]
item.isClick = false
if (cellItem.value === '-9999') {
item.isActived = false
item.value = null
} else {
item.isActived = true
item.value = cellItem.value
}
closeMenuPopup()
emit('confirm', callbackData, getMenuValue())
}
/**
* 菜单项-点击
* @param item 菜单项
* @param index 菜单项索引
*/
function handleItemClick(item, index) {
closeMenuPopup()
if (currentIndex.value === -1) {
currentIndex.value = index
item.value = true
item.isActived = true
} else {
item.value = false
item.isActived = false
clearIndex()
}
if (item?.prop) {
const res = { [item.prop]: item.value }
emit('confirm', res, getMenuValue())
} else {
console.error(`菜单项${item.title}未定义prop返回内容失败`)
}
}
/**
* 菜单项-排序
* @param item 菜单项
* @param index 菜单项索引
*/
function handleItemSort(item, index) {
closeMenuPopup()
if (item.value === 'asc') {
item.value = 'desc'
currentIndex.value = index
item.isActived = true
} else if (item.value === 'desc') {
item.value = undefined
item.isActived = false
clearIndex()
} else {
item.value = 'asc'
currentIndex.value = index
item.isActived = true
}
if (item?.prop) {
const res = { [item.prop]: item.value }
emit('confirm', res, getMenuValue())
} else {
console.error(`菜单项${item.title}未定义prop返回内容失败`)
}
}
/**
* 菜单项-筛选回调
* @param callbackData 操作返回的数据
* @param filterData 筛选数据
* @param index 菜单索引
*/
function handleFilterConfirm(callbackData, filterData, index) {
const dropdownMenu = menuList.value
const item = dropdownMenu[index]
item.isClick = false
item.isActived = JSON.stringify(filterData || {}) !== '{}'
item.value = filterData
closeMenuPopup()
emit('confirm', callbackData, getMenuValue())
}
/**
* 菜单项-级联回调
* @param callbackData 操作返回的数据
* @param pickerItem 级联已选数据
* @param index 菜单索引
*/
function handlePickerConfirm(callbackData, pickerItem, index) {
const dropdownMenu = menuList.value
const item = dropdownMenu[index]
item.isClick = false
if (!pickerItem || pickerItem[0] === '-9999') {
item.isActived = false
item.value = null
} else {
item.isActived = true
item.value = pickerItem
}
closeMenuPopup()
emit('confirm', callbackData, getMenuValue())
}
/**
* 菜单项-日期范围回调
* @param callbackData 操作返回的数据
* @param daterangeItem 日期范围数据
* @param index 菜单索引
*/
function handleDaterangeConfirm(callbackData, daterangeItem, index) {
const dropdownMenu = menuList.value
const item = dropdownMenu[index]
item.isClick = false
if (daterangeItem?.start && daterangeItem?.end) {
item.isActived = true
item.value = daterangeItem
} else {
item.isActived = false
item.value = null
}
closeMenuPopup()
emit('confirm', callbackData, getMenuValue())
}
onMounted(() => {
initDomInfo()
initData()
})
return {
initData,
menuList,
updateMenu,
setMenuLoading,
getMenuIndex,
getMenuList,
dropdownStyle,
currentIndex,
isShow,
isVisible,
hasSearch,
searchItem,
handleSearchChange,
handleSearch,
handleMenuClick,
handlePopupMask,
handleMove,
getMenuValue,
handleCellSelect,
handleFilterConfirm,
handlePickerConfirm,
handleDaterangeConfirm
}
}
})
</script>
<style lang="scss" scoped>
@font-face {
font-family: 'da-dropdown-iconfont'; /* Project id */
src: url('data:application/octet-stream;base64,AAEAAAALAIAAAwAwR1NVQiCLJXoAAAE4AAAAVE9TLzI8GUoGAAABjAAAAGBjbWFwgZ2FYQAAAgQAAAHIZ2x5ZmWuwwYAAAPcAAACHGhlYWQm2YiXAAAA4AAAADZoaGVhB94DhwAAALwAAAAkaG10eBgAAAAAAAHsAAAAGGxvY2EB9gF4AAADzAAAAA5tYXhwARgAVAAAARgAAAAgbmFtZRCjPLAAAAX4AAACZ3Bvc3QrCOz4AAAIYAAAAFsAAQAAA4D/gABcBAAAAAAABAAAAQAAAAAAAAAAAAAAAAAAAAYAAQAAAAEAAMt/P/FfDzz1AAsEAAAAAADh3SJNAAAAAOHdIk0AAP//BAADAQAAAAgAAgAAAAAAAAABAAAABgBIAAgAAAAAAAIAAAAKAAoAAAD/AAAAAAAAAAEAAAAKADAAPgACREZMVAAObGF0bgAaAAQAAAAAAAAAAQAAAAQAAAAAAAAAAQAAAAFsaWdhAAgAAAABAAAAAQAEAAQAAAABAAgAAQAGAAAAAQAAAAQEAAGQAAUAAAKJAswAAACPAokCzAAAAesAMgEIAAACAAUDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFBmRWQAwOYE5zYDgP+AAAAD3ACAAAAAAQAAAAAAAAAAAAAAAAACBAAAAAQAAAAEAAAABAAAAAQAAAAEAAAAAAAABQAAAAMAAAAsAAAABAAAAXwAAQAAAAAAdgADAAEAAAAsAAMACgAAAXwABABKAAAADAAIAAIABOYE5ifmQ+aW5zb//wAA5gTmJ+ZD5pbnNv//AAAAAAAAAAAAAAABAAwADAAMAAwADAAAAAUAAgADAAQAAQAAAQYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAATAAAAAAAAAAFAADmBAAA5gQAAAAFAADmJwAA5icAAAACAADmQwAA5kMAAAADAADmlgAA5pYAAAAEAADnNgAA5zYAAAABAAAAAAAoAJgAwADgAQ4AAAABAAAAAANkAooAEwAAGwEeATcBNi4CBwEOAS8BJg4BFKXqBhMHAa4HAQwSB/5vBg8GzwgQDAGi/vEHAQYB0QcSDQEG/rsEAQSHBAINEQAAAAgAAAAAA3EC+AAIABEAGgAjACwANQA+AEcAAAEUBiImNDYyFgMiBhQWMjY0JiUiJjQ2MhYUBiU0JiIGFBYyNhMWFAYiJjQ2MgEGFBYyNjQmIhMGIiY0NjIWFAEmIgYUFjI2NAJYKz4rKz4rShsmJjYmJgEZFBsbJxsb/dAsPSwsPSxEFiw9LCw9AW0QIC8gIC8yCx8WFh8W/lwWPSwsPSwCrR4sLD0sLP27JjYmJjYmxBwmGxsmHC8fKys+LCwBLRY9LCw9LP4qES4gIC4hAWELFh8VFR/+kRYsPSwsPQAAAQAA//8CwAMBABQAAAE0JzUBFSYiBhQXCQEGFBYyNxUBNgLACP7AChsTCAEt/tMIExsKAUAIAYAMCQEBYAELExkJ/rX+tQkZEwsBAWEJAAACAAAAAAN0AsEADQAOAAAlATcXNjc2NxcGBwYHBgcBz/7XTa5QWYeOFF1cT0I7H1oBLz2FW1J7WClWdGRrX0YAAQAAAAADWQJKABkAAAEyHgEGBw4BBw4CJicmLwImJy4BPgEzNwMbFx0JCRBAdzcPKSooDR8hRUIgHQ0ICRsWtgJKEhwkEUeIPBARAQ4QIiNHRiMgDyEbEQEAAAAAABIA3gABAAAAAAAAABMAAAABAAAAAAABAAgAEwABAAAAAAACAAcAGwABAAAAAAADAAgAIgABAAAAAAAEAAgAKgABAAAAAAAFAAsAMgABAAAAAAAGAAgAPQABAAAAAAAKACsARQABAAAAAAALABMAcAADAAEECQAAACYAgwADAAEECQABABAAqQADAAEECQACAA4AuQADAAEECQADABAAxwADAAEECQAEABAA1wADAAEECQAFABYA5wADAAEECQAGABAA/QADAAEECQAKAFYBDQADAAEECQALACYBY0NyZWF0ZWQgYnkgaWNvbmZvbnRpY29uZm9udFJlZ3VsYXJpY29uZm9udGljb25mb250VmVyc2lvbiAxLjBpY29uZm9udEdlbmVyYXRlZCBieSBzdmcydHRmIGZyb20gRm9udGVsbG8gcHJvamVjdC5odHRwOi8vZm9udGVsbG8uY29tAEMAcgBlAGEAdABlAGQAIABiAHkAIABpAGMAbwBuAGYAbwBuAHQAaQBjAG8AbgBmAG8AbgB0AFIAZQBnAHUAbABhAHIAaQBjAG8AbgBmAG8AbgB0AGkAYwBvAG4AZgBvAG4AdABWAGUAcgBzAGkAbwBuACAAMQAuADAAaQBjAG8AbgBmAG8AbgB0AEcAZQBuAGUAcgBhAHQAZQBkACAAYgB5ACAAcwB2AGcAMgB0AHQAZgAgAGYAcgBvAG0AIABGAG8AbgB0AGUAbABsAG8AIABwAHIAbwBqAGUAYwB0AC4AaAB0AHQAcAA6AC8ALwBmAG8AbgB0AGUAbABsAG8ALgBjAG8AbQAAAgAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQIBAwEEAQUBBgEHAAdnb3V4dWFuBmppYXphaQp5b3VqaWFudG91BnhpYXphaQh4aWFuZ3hpYQAAAA==')
format('truetype');
}
.da-dropdown {
--dropdown-menu-height: 80rpx;
--dropdown-popup-duration: 0.3s;
position: relative;
z-index: 888;
width: 100%;
line-height: 1;
&--blank {
width: 100%;
height: var(--dropdown-menu-height);
}
&-search {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: var(--dropdown-menu-height);
padding: 10rpx 20rpx 6rpx;
background: var(--dropdown-background-color, #fff);
&-input {
flex-grow: 1;
height: 60rpx;
padding: 0 20rpx;
overflow: hidden;
font-size: 28rpx;
color: var(--dropdown-text-color);
background: #f6f6f6;
border-radius: 8rpx 0 0 8rpx;
}
&-btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
height: 60rpx;
padding: 0 20rpx;
overflow: hidden;
font-size: 28rpx;
color: var(--dropdown-text-color);
background: #f6f6f6;
border: none;
border-radius: 0 8rpx 8rpx 0;
&::after {
display: none;
}
}
}
&-menu {
position: relative;
z-index: 1;
display: flex;
align-items: center;
height: var(--dropdown-menu-height);
background: var(--dropdown-background-color, #fff);
box-shadow: 0 1rpx 0 0 #bbb;
&-item {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
height: 100%;
&:hover {
background: #fafafa;
}
&.is-hidden {
display: none;
}
&--text {
font-size: 24rpx;
color: var(--dropdown-text-color);
&.is-actived {
color: var(--dropdown-theme-color);
}
}
&--icon {
flex-shrink: 0;
margin-left: 2px;
color: #bbb;
.is--loading,
.is--arrup,
.is--arrdown {
display: flex;
align-items: center;
justify-content: center;
width: 24rpx;
height: 24rpx;
&::after {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'da-dropdown-iconfont' !important;
font-size: 24rpx;
font-style: normal;
content: '\e604';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
.is--loading {
animation: RunLoading 1s linear 0s infinite;
&::after {
content: '\e627';
}
}
.is--arrup {
color: var(--dropdown-theme-color);
transform: rotate(180deg);
}
}
&--sort {
position: relative;
margin-left: 6rpx;
transition: transform 0.3s;
&::before,
&::after {
position: absolute;
top: calc(50% - 16rpx);
left: 0;
content: '';
border-color: transparent;
border-style: solid;
border-width: 8rpx;
border-bottom-color: #bbb;
}
&::after {
top: calc(50% + 6rpx);
border-top-color: #bbb;
border-bottom-color: transparent;
}
&.is--asc::before {
border-bottom-color: var(--dropdown-theme-color);
}
&.is--desc::after {
border-top-color: var(--dropdown-theme-color);
}
}
}
}
&-content {
position: absolute;
top: var(--dropdown-menu-height);
left: 0;
z-index: -1;
box-sizing: border-box;
width: 100%;
overflow: hidden;
visibility: hidden;
box-shadow: 0 -1rpx 0 0 #bbb;
opacity: 0;
transition: all var(--dropdown-popup-duration, 0.3s) linear;
&.is-show {
z-index: 901;
opacity: 1;
}
&.is-visible {
visibility: visible;
animation: CustomBS var(--dropdown-popup-duration) linear var(--dropdown-popup-duration)
forwards;
}
&-mask {
position: absolute;
top: 0;
bottom: 0;
left: 0;
z-index: 9;
width: 100%;
background: rgba(0, 0, 0, 0.3);
}
&-popup {
position: relative;
z-index: 10;
max-height: 100%;
overflow: auto;
transition: transform var(--dropdown-popup-duration) linear;
transform: translateY(-100%);
&.is-show {
// transform: translateY(0);
transform: translateY(-50%);
}
}
}
&-popup-box {
width: 100%;
height: 100%;
overflow: hidden;
font-size: 28rpx;
line-height: 1;
background: var(--dropdown-background-color, #fff);
transition: border-radius var(--dropdown-popup-duration) linear;
}
&.has-search {
.da-dropdown {
&-content {
top: calc(var(--dropdown-menu-height) + var(--dropdown-menu-height));
}
}
}
/* 固定至顶 */
&.is-fixed {
z-index: 980;
.da-dropdown {
&-search {
position: fixed;
top: calc(var(--window-top, 0px) + var(--dropdown-fixed-top, 0px));
right: 0;
left: 0;
max-width: 1190px;
margin: auto;
}
&-menu {
position: fixed;
top: calc(var(--window-top, 0px) + var(--dropdown-fixed-top, 0px));
right: 0;
left: 0;
max-width: 1190px;
margin: auto;
}
&-content {
position: fixed;
top: calc(
var(--window-top, 0px) + var(--dropdown-fixed-top, 0px) +
var(--dropdown-menu-height, 0px)
);
right: 0;
bottom: 0;
left: 0;
height: 100%;
box-shadow: none;
}
}
&.has-search {
.da-dropdown {
&-menu {
top: calc(
var(--window-top, 0px) + var(--dropdown-fixed-top, 0px) +
var(--dropdown-menu-height, 0px)
);
}
&-content {
top: calc(
var(--window-top, 0px) + var(--dropdown-fixed-top, 0px) +
var(--dropdown-menu-height, 0px) + var(--dropdown-menu-height, 0px)
);
}
&--blank {
height: calc(
var(--dropdown-fixed-top, 0px) + var(--dropdown-menu-height, 0px) +
var(--dropdown-menu-height, 0px)
);
}
}
}
}
}
@keyframes RunLoading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes CustomBS {
0% {
box-shadow: 0 -1rpx 0 0 #bbb;
}
100% {
box-shadow: 0 -1rpx 0 0 #bbb, 0 20rpx 20rpx -10rpx rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@ -0,0 +1,443 @@
# da-dropdown
一个基于 Vue3 的头部导航栏下拉弹窗组件,多平台兼容。
组件一直在更新,遇到问题可在下方讨论。
`同时更新 Vue2 版本,在此查看 ===>` **[Vue2 版](https://ext.dcloud.net.cn/plugin?id=13062)**
### 关于使用
可在右侧的`使用 HBuilderX 导入插件`或`下载示例项目ZIP`,示例项目已添加多个示例,方便快速上手。
可通过下方的示例及文档说明,进一步了解使用组件相关细节参数。
插件地址https://ext.dcloud.net.cn/plugin?id=11840
### 功能一览
1. 下拉列表(单选)
2. 点击常亮
3. 点击排序
4. 下拉筛选(单选按钮、多选按钮、滑动选择器)
5. 级联筛选(单选)
6. 日期筛选(日期快选、日期区间选择)
7. 顶部搜索
8. 自定插槽
### 组件示例
```jsx
<template>
<DaDropdown
:dropdownMenu="dropdownMenuList"
themeColor="#007aff"
textColor="#333333"
:duration="300"
fixedTop
@confirm="handleConfirm"
@close="handleClose"
@open="handleOpen">
<template #slot1="{item,index}">
<view style="padding: 40px">自定义插槽内容</view>
</template>
</DaDropdown>
</template>
```
```js
import { defineComponent, ref } from 'vue'
import DaDropdown from '@/components/da-dropdown/index.vue'
export default defineComponent({
components: { DaDropdown },
setup() {
const dropdownMenuList = ref([
// 演示数据请看下方各模块说明或下载示例项目查看
// ...
])
function handleConfirm(v) {
console.log('handleConfirm ==>', v)
}
function handleClose(v) {
console.log('handleClose ==>', v)
}
function handleOpen(v) {
console.log('handleOpen ==>', v)
}
return {
dropdownMenuList,
handleConfirm,
handleClose,
handleOpen,
}
},
})
```
### 组件参数
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :------------------- | :-------- | :-------- | :--- | :--------------------------------- |
| v-model:dropdownMenu | `Array` | `[]` | 是 | 导航菜单数据 |
| themeColor | `String` | `#007aff` | 否 | 主题颜色 |
| textColor | `String` | `#333333` | 否 | 导航文字颜色 |
| bgColor | `String` | `#ffffff` | 否 | 背景颜色,当固定在顶部时,此为必填 |
| fixedTop | `Boolean` | `false` | 否 | 是否固定在顶部 |
| fixedTopValue | `Number` | `0` | 否 | 固定在头部时的位置,单位 px |
| duration | `Number` | `300` | 否 | 弹窗动画的过渡时间 |
> 温馨提示:如果页面定义了 "navigationStyle": "custom" ,因此固定头部时需要额外获取状态栏高度,以免被异形屏头部覆盖,此时的 fixedTopValue 的作用就出来了,通过 fixedTopValue 自定义加减固定头部所处的位置。
### 组件事件
| 事件名称 | 回调参数 | 说明 |
| :------- | :------------------------- | :----------------------------------------------------------------- |
| open | `(index) => void` | 打开弹窗时回调 |
| close | `(index,menuList) => void` | 关闭弹窗时回调 |
| confirm | `(value,data) => void` | 确定选择内容时回调,返回选择的数据,格式`{'菜单项prop值': '内容'}` |
### 组件方法
| 事件名称 | 回调参数 | 说明 |
| :---------------- | :------------------------- | :-------------------------------------- |
| openMenuItemPopup | `(index) => void` | 打开指定位置的菜单项弹窗 |
| closeMenuPopup | `() => void` | 关闭菜单项弹窗 |
| getMenuValue | `() => object` | 获取菜单存在的值 |
| updateMenu | `(prop,value,key) => void` | 更新菜单项内容【参考示例7】 |
| setMenuLoading | `(prop,state) => void` | 操作指定菜单项为加载中状态【参考示例7】 |
| getMenuIndex | `(prop) => number` | 获取菜单项所在索引位置 |
| getMenuList | `() => array` | 获取当前菜单列表数据【参考示例6】 |
### 组件菜单项
#### dropdownMenu 基础参数
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :---------- | :--------- | :----- | :--- | :--------------------------------------------------------------- |
| title | `String` | - | 是 | 菜单名称 |
| prop | `String` | - | 是 | 菜单 prop 值,**菜单项的 prop 是唯一的** |
| type | `String` | - | 是 | 菜单类型,参考下方类型说明 |
| syncDataFn | `Function` | - | 否 | 异步函数返回子项数据,优先级大于 options |
| syncDataKey | `String` | - | 否 | 异步数据不是根数据时需要。支持嵌套,如:`data.list`【参考示例7】 |
除上方基础参数以外,不同的菜单项(type)会有额外的配置参数
**type 说明**
**cell** 下拉列表
**click** 点击
**sort** 排序
**filter** 复杂筛选
**picker** 级联
**daterange** 日期范围
**search** 搜索框(菜单项 type 唯一)
**slot** 弹窗插槽
#### 菜单项 - 下拉列表(cell)
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :------- | :----------------- | :----------------------------------------------------- | :--- | :----------------------------------------- |
| value | `Number`\|`String` | - | 否 | 默认值,和`options`的 value 必须保持同类型 |
| showAll | `Boolean` | `false` | 否 | 是否显示 “不限” 项 |
| showIcon | `Boolean` | `false` | 否 | 是否在选中时显示勾选图标 |
| field | `Object` | `{ label: 'label', value: 'value', suffix: 'suffix' }` | 否 | 列表子项数据对应内容字段 |
| options | `Array` | `[]` | 否 | 下拉列表子项数据 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '下拉',
type: 'cell',
prop: 'god1',
showAll: true,
showIcon: true,
// value: '2', // 默认内容2
options: [
{ label: '下拉列表项1', value: '1', suffix: '副标题' },
{ label: '下拉列表项2', value: '2' },
{ label: '下拉列表项3', value: '3' },
],
},
]
```
#### 菜单项 - 高亮(click)
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :---- | :-------- | :----- | :--- | :-------------------------------- |
| value | `Boolean` | - | 否 | 默认值true 选中、false 取消选中 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '点击',
type: 'click',
prop: 'god2',
// value: true, // 默认选中
},
]
```
#### 菜单项 - 排序(sort)
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :---- | :------------ | :----- | :--- | :-------------------------- |
| value | `asc`\|`desc` | - | 否 | 默认值asc 升序、desc 倒序 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '排序',
type: 'sort',
prop: 'god3',
// value: 'asc', // 默认升序
},
]
```
#### 菜单项 - 筛选(filter)
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :------ | :------- | :----- | :--- | :------------------------------------------- |
| value | `Object` | - | 否 | 默认值,格式`{ prop1: '值1', prop2: '值2' }` |
| options | `Array` | `[]` | 否 | 筛选子项数据,**说明见下** |
##### filter -> options 参数说明
| 属性 | 类型 | 必填 | 说明 |
| :------------- | :---------------------------- | :--- | :-------------------------------------------------------------------------------------------- |
| title | `String` | 是 | 筛选项的子项标题 |
| type | `radio`\|`checkbox`\|`slider` | 是 | 筛选项的子项类型,可选 radio 单选按钮、checkbox 多选按钮、slider 滑动选择器 |
| prop | `String` | 是 | 筛选项的子项 prop**注意保持子项 prop 唯一** |
| componentProps | `Object` | 否 | 筛选项的对应的组件配置,[slider 组件配置](https://uniapp.dcloud.net.cn/component/slider.html) |
| options | `Array` | 否 | 筛选子项的类型对应的数据 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '筛选',
type: 'filter',
prop: 'god4',
// 默认选中单选2、多选2、3、滑动30
// value: { ft1: '2', ft2: ['2', '3'], ft3: 30 },
options: [
{
title: '单选',
type: 'radio',
prop: 'ft1',
options: [
{ label: '单选1', value: '1' },
{ label: '单选2', value: '2' }
],
},
{
title: '多选',
type: 'checkbox',
prop: 'ft2',
options: [
{ label: '多选1', value: '1' },
{ label: '多选2', value: '2' }
],
},
{
title: '滑块',
type: 'slider',
prop: 'ft3',
componentProps: {
min: 0,
max: 100,
step: 1,
showValue: true,
},
},
],
},
]
```
#### 菜单项 - 级联(picker)
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :---------- | :--------- | :--------------------------------------------------------- | :--- | :--------------------------------------------------------------- |
| value | `Array` | - | 否 | 默认值,格式`['一级value', '二级value']` |
| showAll | `Boolean` | `false` | 否 | 是否显示 “不限” 项 |
| showIcon | `Boolean` | `false` | 否 | 是否在选中末级时显示勾选图标 |
| field | `Object` | `{ label: 'label', value: 'value', children: 'children' }` | 否 | 级联子项数据对应内容字段 |
| options | `Array` | `[]` | 否 | 级联子项数据 |
| syncDataFn | `Function` | - | 否 | 异步函数返回级联子项数据,优先级大于 options |
| syncDataKey | `String` | - | 否 | 异步数据不是根数据时需要。支持嵌套,如:`data.list`【参考示例7】 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '级联选择',
type: 'picker',
prop: 'god5',
showAll: true,
showIcon: true,
// showAll 为true时相当于在options第一的位置插入“不限”项
// { label: '不限', value: '-9999' },
field: {
label: 'label',
value: 'value',
children: 'children',
},
// value: ['2', '22'], // 默认选中 级联X22
options: [
{
label: '级联X1',
value: '1',
children: [
{ label: '级联X11', value: '11' },
{ label: '级联X12', value: '12' },
],
},
{
label: '级联X2',
value: '2',
children: [
{ label: '级联X21', value: '21' },
{ label: '级联X22', value: '22' },
],
},
],
},
]
```
#### 菜单项 - 日期(daterange)
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :-------- | :-------- | :----- | :--- | :--------------------------------------------------- |
| value | `Object` | - | 否 | 默认值,格式`{ start: '开始日期', end: '结束日期' }` |
| showQuick | `Boolean` | `true` | 否 | 是否显示日期快选 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '日期范围',
type: 'daterange',
prop: 'god6',
// 默认选中 2022-01-01到2022-02-01
// value: { start: '2022-01-01', end: '2022-02-01' },
},
]
```
#### 菜单项 - 顶部搜索框(search)
当存在此类型时,头部将会展示搜索框,**注意:此类型唯一**
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :---- | :------- | :----- | :--- | :----- |
| value | `String` | - | 否 | 默认值 |
```js
// 简单示例
const dropdownMenuList = [
{
title: '搜索',
type: 'search',
prop: 'god0',
},
]
```
#### 菜单项 - 拓展插槽(slot1、slot2、slot3、slot4、slot5)
拓展插槽有 5 个,足以应付业务需求了,类型名称为`slot1`、`slot2`、`slot3`、`slot4`、`slot5`,这是固定的类型值
| 属性 | 类型 | 默认值 | 必填 | 说明 |
| :---- | :------- | :----- | :--- | :----- |
| value | `String` | - | 否 | 默认值 |
```jsx
// 简单示例
<template>
<DaDropdown>
<template #slot1="{item,index}">
<view>自定义插槽2内容 {{item.value}} {{index}}</view>
</template>
<template #slot2="{item,index}">
<view>自定义插槽2内容 {{item.value}} {{index}}</view>
</template>
<template #slot3="{item,index}">
<view>自定义插槽3内容 {{item.value}} {{index}}</view>
</template>
<template #slot4="{item,index}">
<view>自定义插槽4内容 {{item.value}} {{index}}</view>
</template>
<template #slot5="{item,index}">
<view>自定义插槽5内容 {{item.value}} {{index}}</view>
</template>
</DaDropdown>
</template>
```
```js
const dropdownMenuList = [
{
title: '插槽1',
type: 'slot1',
prop: 'god1',
},
{
title: '插槽2',
type: 'slot2',
prop: 'god2',
},
{
title: '插槽3',
type: 'slot3',
prop: 'god3',
},
{
title: '插槽4',
type: 'slot4',
prop: 'god4',
},
{
title: '插槽5',
type: 'slot5',
prop: 'god5',
},
]
```
### 组件版本
v2.2.2
### 差异化
已通过测试
> - H5 页面
> - 微信小程序
> - 支付宝、钉钉小程序
> - 字节跳动、抖音、今日头条小程序
> - 百度小程序
> - 飞书小程序
> - QQ 小程序
> - 京东小程序
未测试
> - 快手小程序由于非企业用户暂无演示
> - 快应用、360 小程序因 Vue3 支持的原因暂无演示
### 开发组
[@CRLANG](https://crlang.com)

View File

@ -0,0 +1,151 @@
/**
* -
*/
export interface DaCellOption {
/**
*
*/
showAll?: boolean
/**
*
*/
showIcon?: boolean
}
/**
* -
*/
export interface DaClickOption {}
/**
* -
*/
export interface DaSortOption {}
/**
* -
*/
export interface DaFilterOption {}
/**
* -
*/
export interface DaPickerOption {
/**
*
*/
showAll?: boolean
/**
*
*/
showIcon?: boolean
field?: {
label: string
value: string
children: string
}
}
/**
* -
*/
export interface DaDaterangeOption {
value?: {
start: string
end: string
}
}
/**
* -
*/
export interface DaCellItemOption extends DaDropdownMenuListOption {
/**
*
*/
suffix?: string
}
/**
* -
*/
export interface DaFilterItemOption {
/**
*
*/
title: string
/**
* radio checkbox slider
*/
type: 'radio' | 'checkbox' | 'slider'
/**
* prop
*/
prop: string
/**
*
*/
value?: string | number | string[] | number[]
/**
* -sliderprop https://uniapp.dcloud.net.cn/component/slider.html
*/
componentProp?: object
/**
* -
*/
options?: DaDropdownMenuListOption[]
}
/**
* -
*/
export interface DaPickerItem extends DaDropdownMenuListOption {
isActived: boolean
/**
*
*/
children?: DaPickerItem[]
}
/**
*
*/
export interface DaDropdownMenuListOption {
/**
*
*/
label: string
/**
*
*/
value: string
}
/**
*
*/
export interface DaDropdownMenuListItem extends DaCellOption, DaClickOption, DaSortOption, DaFilterOption, DaPickerOption {
/**
*
*/
title: string
/**
*
* cell click sort filter picker daterange
*/
type: 'cell' |'click' | 'sort' | 'filter' | 'picker'| 'daterange'
/**
* prop
*/
prop: string
/**
*
*/
value?: string
/**
* options
*/
syncDataFn?: Function
/**
*
*/
options?: DaDropdownMenuListOption[] | DaFilterItemOption[]
}
/**
*
*/
export type DaDropdownMenuList = DaDropdownMenuListItem[]

View File

@ -0,0 +1,215 @@
/**
*
* @param originData
* @author crlang(https://crlang.com)
*/
export function deepClone(originData) {
const type = Object.prototype.toString.call(originData)
let data
if (type === '[object Array]') {
data = []
for (let i = 0; i < originData.length; i++) {
data.push(deepClone(originData[i]))
}
} else if (type === '[object Object]') {
data = {}
for (const prop in originData) {
// eslint-disable-next-line no-prototype-builtins
if (originData.hasOwnProperty(prop)) {
// 非继承属性
data[prop] = deepClone(originData[prop])
}
}
} else {
data = originData
}
return data
}
export function getValueByKey(object, path, defaultVal = undefined) {
console.log('object, path', object, path)
// 先将path处理成统一格式
let newPath = []
if (Array.isArray(path)) {
newPath = path
} else {
// 先将字符串中的'['、']'去除替换为'.'split分割成数组形式
newPath = path.replace(/\[/g, '.').replace(/\]/g, '').split('.')
}
// 递归处理,返回最后结果
return (
newPath.reduce((o, k) => {
console.log(o, k) // 此处o初始值为下边传入的 object后续值为每次取的内部值
return (o || {})[k]
}, object) || defaultVal
)
}
/**
*
* @param data
*/
export function checkDataField(options, fields) {
if (!fields || !options || options.length === 0) {
return options
}
for (let i = 0; i < options.length; i++) {
const k = options[i]
k.label = k[fields.label || 'label'] || null
k.value = k[fields.value || 'value'] || null
k.suffix = k[fields.suffix || 'suffix'] || null
k.children = k[fields.children || 'children'] || null
if (k.children?.length) {
k.options = checkDataField(k.options)
}
}
return options
}
/**
* -
* @param n
* @author crlang(https://crlang.com)
*/
export function formatNumber(n) {
let s = parseInt(n)
if (isNaN(s)) {
s = '0'
} else {
s = s.toString()
}
return s[1] ? s : `0${s}`
}
/**
*
* @param date
* @param format
* @author crlang(https://crlang.com)
*/
export function formatTime(date, format) {
const daDate = new Date(date.toString().length < 11 ? date * 1000 : date)
const fromatsRule = ['y', 'm', 'd', 'h', 'i', 's']
let tmp = []
const year = daDate.getFullYear()
const month = daDate.getMonth() + 1
const day = daDate.getDate()
const hour = daDate.getHours()
const minute = daDate.getMinutes()
const second = daDate.getSeconds()
if (format) {
tmp.push(year, month, day, hour, minute, second)
tmp = tmp.map(formatNumber)
for (let i = 0; i < tmp.length; i++) {
format = format.toLowerCase().replace(fromatsRule[i], tmp[i])
}
return format
}
return `${[year, month, day].map(formatNumber).join('/')} ${[hour, minute, second]
.map(formatNumber)
.join(':')}`
}
/**
*
*
* @param v -1-7-14-30-60
* @returns object {start: y-m-d,end: y-m-d}
* @author crlang(https://crlang.com)
*/
export function getRangeDate(v) {
const now = new Date()
const nowTime = now.getTime()
const oneDay = 24 * 60 * 60 * 1000
const dateRange = { start: '', end: '' }
const nowWeekDay = now.getDay() // 今天本周的第几天
const nowDay = now.getDate() // 当前日
const nowMonth = now.getMonth() // 当前月
const nowYear = now.getFullYear() // 当前年
/**
*
* @param month
*/
const getMonthDays = function (month) {
const monthStartDate = new Date(nowYear, month, 1)
const monthEndDate = new Date(nowYear, month + 1, 1)
const days = (monthEndDate - monthStartDate) / oneDay
return days
}
// 昨日
if (v === '-1') {
dateRange.start = formatTime(new Date(nowTime - oneDay), 'y-m-d')
dateRange.end = dateRange.start
// 本周
} else if (v === '-7') {
const weekStart = new Date(nowYear, nowMonth, nowDay - nowWeekDay + 1)
const weekEnd = new Date(nowTime + oneDay) // 今日
dateRange.start = formatTime(weekStart, 'y-m-d')
dateRange.end = formatTime(weekEnd, 'y-m-d')
// 上周
} else if (v === '-14') {
const weekStart = new Date(nowYear, nowMonth, nowDay - nowWeekDay - 6)
const weekEnd = new Date(nowYear, nowMonth, nowDay - nowWeekDay)
dateRange.start = formatTime(weekStart, 'y-m-d')
dateRange.end = formatTime(weekEnd, 'y-m-d')
// 本月
} else if (v === '-30') {
const monthStart = new Date(nowYear, nowMonth, 1)
const monthEnd = new Date(nowTime + oneDay)
dateRange.start = formatTime(monthStart, 'y-m-d')
dateRange.end = formatTime(monthEnd, 'y-m-d')
// 上月
} else if (v === '-60') {
const lastMonthDate = new Date() // 上月日期
lastMonthDate.setDate(1)
lastMonthDate.setMonth(lastMonthDate.getMonth() - 1)
const lastMonth = lastMonthDate.getMonth()
const lastMonthStart = new Date(nowMonth === 0 ? nowYear - 1 : nowYear, lastMonth, 1)
const lastMonthEnd = new Date(
nowMonth === 0 ? nowYear - 1 : nowYear,
lastMonth,
getMonthDays(lastMonth)
)
dateRange.start = formatTime(lastMonthStart, 'y-m-d')
dateRange.end = formatTime(lastMonthEnd, 'y-m-d')
} else {
// 传入 v 为整数是即为近 xx 天
if (v > 0) {
dateRange.start = formatTime(new Date(nowTime - oneDay * parseInt(v)), 'y-m-d')
dateRange.end = formatTime(new Date(nowTime - oneDay), 'y-m-d') // 不含今天
}
}
return dateRange
}
export const menuInitOpts = {
cell: {
showArrow: true
},
click: {},
sort: {
showSort: true
},
filter: {
showArrow: true
},
picker: {
showArrow: true
},
daterange: {
showQuick: true,
showArrow: true
},
slot: {
showArrow: true
},
search: {
showSearch: true
}
}

View File

@ -21,7 +21,10 @@
@click="cellClick($event, ite.name, item[ite.name])"
:id="`${index + 1}-${indey}`"
>
{{ ite.filters ? itemFilter(item, ite) : item[ite.name] }}
<slot v-if="ite.slot" name="index" :row="item[ite.name]" />
<template v-else>
{{ ite.filters ? itemFilter(item, ite) : item[ite.name] }}
</template>
</view>
</view>
</view>
@ -36,6 +39,8 @@ interface IColumn {
name: string
label: string
width: number
align: string
slot?: boolean
}
interface IData {
[key: string]: [string, number]
@ -63,7 +68,8 @@ const thStyle = computed(() => (column: IColumn) => {
const { width } = column
return {
width: `${width ? width : '100'}px`,
minWidth: `${width ? width : '100'}px`
minWidth: `${width ? width : '100'}px`,
textAlign: column.align ?? 'left'
}
})
const advanceData = ref<IData[]>([])
@ -109,14 +115,14 @@ transData()
align-items: center;
width: 100%;
.design-table-th {
background: $gray-1;
// background: $gray-1;
height: 100%;
line-height: 100rpx;
padding: 0 40rpx 0 16rpx;
border: 1px solid $gray-6;
border-width: 1px;
border-style: solid;
border-color: $gray-6 transparent $gray-6 $gray-6;
// border: 1px solid $gray-6;
// border-width: 1px;
// border-style: solid;
// border-color: $gray-6 transparent $gray-6 $gray-6;
font-size: 30rpx;
&:last-child {
border-right-color: $gray-6;
@ -130,12 +136,12 @@ transData()
height: 100rpx;
line-height: 100rpx;
padding: 0 40rpx 0 16rpx;
border-width: 1px;
border-style: solid;
border-color: transparent transparent $gray-6 $gray-6;
&:last-child {
border-right-color: $gray-6;
}
// border-width: 1px;
// border-style: solid;
// border-color: transparent transparent $gray-6 $gray-6;
// &:last-child {
// border-right-color: $gray-6;
// }
//
overflow: hidden;
text-overflow: ellipsis;

View File

@ -0,0 +1,18 @@
<template>
<view class="flex flex-col bg-white rounded-[16rpx] mb-[24rpx]">
<text class="font-bold p-[24rpx]" v-if="title">{{ title }}</text>
<view>
<slot />
</view>
</view>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: ''
}
})
</script>
<style scoped></style>

View File

@ -0,0 +1,223 @@
<template>
<view class="da-dropdown-picker" v-if="viewCol && viewCol.length">
<view class="da-dropdown-picker-inner" v-for="(vc, vci) in viewCol" :key="vci">
<scroll-view class="da-dropdown-picker-view" scroll-y>
<view
class="da-dropdown-picker-item"
:class="vr.checked ? 'is-actived' : ''"
v-for="(vr, vri) in viewRow[vci]"
:key="vri"
@click="handleSelect(vr, vci, vri)"
>
<text class="da-dropdown-picker-item--name">{{ vr.label }}</text>
<text
class="da-dropdown-picker-item--icon"
v-if="vr.children && vr.children.length"
></text>
<text
class="da-dropdown-picker-item--check"
v-if="vr.checked && (!vr.children || vr.children.length === 0)"
/>
</view>
</scroll-view>
</view>
</view>
</template>
<script>
import { deepClone } from '@/components/da-dropdown/utils'
import { defineComponent, ref, watch } from 'vue'
export default defineComponent({
props: {
dropdownItem: {
type: Object,
default: null
},
dropdownIndex: {
type: Number
}
},
emits: ['success'],
setup(props, { emit }) {
const viewCol = ref([])
const viewRow = ref([])
function checkData(selected, list) {
for (let i = 0; i < list.length; i++) {
const k = list[i]
for (let j = 0; j < selected.length; j++) {
const x = selected[j]
if (k.value === x) {
k.checked = true
viewCol.value.push(k.value)
viewRow.value.push(list)
if (k.children?.length) {
checkData(selected, k.children)
}
break
}
}
}
}
function initData(item) {
const list = deepClone(item || [])
if (list?.length) {
if (item.value?.length) {
viewCol.value = []
viewRow.value = []
checkData(item.value, list)
} else {
viewCol.value.push('tmpValue')
viewRow.value.push(list)
}
} else {
viewCol.value = []
viewRow.value = []
}
}
function handleSelect(item, colIndex, _rowIndex) {
let lastItem = false
viewCol.value.splice(colIndex)
viewCol.value[colIndex] = item.value
if (viewRow.value[colIndex]?.length) {
viewRow.value[colIndex].forEach(k => {
k.checked = false
})
}
item.checked = true
const list = item?.children || null
if (list?.length) {
viewCol.value[colIndex + 1] = 'tmpValue'
viewRow.value[colIndex + 1] = list
lastItem = false
} else {
console.warn('最后一项', item)
lastItem = true
}
try {
if (viewRow.value[colIndex + 1]?.length) {
viewRow.value[colIndex + 1].forEach(k => {
k.checked = false
})
}
} catch (e) {
console.warn('try clean row data', e)
// --
}
if (lastItem) {
if (props.dropdownItem?.prop) {
// const res = { [props.dropdownItem.prop]: deepClone(viewCol.value) }
// emit('success', res, viewCol.value, props.dropdownIndex)
} else {
// console.error(`${props.dropdownItem.title}prop`)
}
}
}
watch(
() => props.dropdownItem,
v => {
console.log(v)
initData(v)
},
{ immediate: true }
)
return {
viewCol,
viewRow,
handleSelect
}
}
})
</script>
<style lang="scss" scoped>
.da-dropdown-picker {
display: flex;
width: 100%;
max-height: 60vh;
overflow: hidden;
line-height: 1;
&-inner {
flex-grow: 1;
}
&-view {
display: flex;
/* #ifdef MP-ALIPAY */
flex-direction: column;
flex-wrap: wrap;
/* #endif */
width: 100%;
height: 100%;
+ .da-dropdown-picker-view {
border-left: 1px solid #eee;
}
}
&-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
font-size: 24rpx;
color: var(--dropdown-text-color);
text-align: left;
&--icon {
width: 24rpx;
height: 24rpx;
&::after {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'iconfont' !important;
font-size: 24rpx;
font-style: normal;
content: '\e600';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
&--check {
flex-shrink: 0;
width: 24rpx;
height: 24rpx;
&::after {
/* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
font-family: 'iconfont' !important;
font-size: 24rpx;
font-style: normal;
content: '\e677';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
&:hover {
background: #eee;
}
&.is-actived {
color: $blue-1;
}
}
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<view>
<text></text>
</view>
</template>
<script setup lang="ts">
defineProps({
tabs: {
type: Array,
default: () => []
}
})
</script>
<style scoped></style>

View File

@ -0,0 +1,52 @@
<template>
<card>
<view class="flex flex-col mb-[24rpx]" @click="handleCardClick">
<view class="flex items-center p-[24rpx] gap-[24rpx]">
<view
class="bg-primary rounded-full text-white w-[120rpx] h-[120rpx] flex items-center justify-center"
>
张三
</view>
<view class="flex-1 flex flex-col gap-[12rpx]">
<text>张三</text>
<text class="text-muted">湛江团队-A组 . 招生老师</text>
</view>
</view>
<view class="flex gap-[12rpx] flex-wrap">
<view
class="w-[30%] flex justify-center items-center flex-col gap-[12rpx]"
v-for="(item, index) in data"
:key="`unique_${index}`"
>
<text class="text-muted">{{ item.label }}</text>
<text class="font-bold text-[40rpx]">{{ item.value }}</text>
</view>
</view>
</view>
</card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import card from '../../card.vue'
const props = defineProps({
item: {
type: Object,
default: () => {}
}
})
const emit = defineEmits(['handleCardClick'])
const data = ref([
{ label: '转化中', value: 368 },
{ label: '已添加', value: 100 },
{ label: '异常待处理', value: 368 },
{ label: '成交客户', value: 368 },
{ label: '战败客户', value: 368 }
])
const handleCardClick = () => {
emit('handleCardClick', 'recruitsale', props.item)
}
</script>
<style scoped></style>

View File

@ -0,0 +1,52 @@
<template>
<card>
<view class="flex flex-col mb-[24rpx]" @click="handleCardClick">
<view class="flex items-center p-[24rpx] gap-[24rpx]">
<view
class="bg-primary rounded-full text-white w-[120rpx] h-[120rpx] flex items-center justify-center"
>
张三
</view>
<view class="flex-1 flex flex-col gap-[12rpx]">
<text>张三</text>
<text class="text-muted">湛江团队-A组 . 电销老师</text>
</view>
</view>
<view class="flex gap-[12rpx]">
<view
class="flex-1 flex justify-center items-center flex-col gap-[12rpx]"
v-for="(item, index) in data"
:key="`unique_${index}`"
>
<text class="text-muted">{{ item.label }}</text>
<text class="font-bold text-[40rpx]">{{ item.value }}</text>
</view>
</view>
</view>
</card>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import card from '../../card.vue'
const props = defineProps({
item: {
type: Object,
default: () => {}
}
})
const emit = defineEmits(['handleCardClick'])
const data = ref([
{ label: '未领取', value: 368 },
{ label: '已领取', value: 100 },
{ label: '异常待处理', value: 368 }
])
const handleCardClick = () => {
emit('handleCardClick', 'telesale', props.item)
}
</script>
<style scoped></style>

View File

@ -0,0 +1,161 @@
<template>
<view class="flex-1 h-full flex flex-col">
<u-dropdown ref="uDropdownRef" menu-icon="arrow-down-fill">
<u-dropdown-item title="岗位">
<view class="bg-white">
<view>
<view
v-for="item in positionList"
:key="item.id"
class="cell p-[24rpx] border-b border-solid border-[#f1f1f1]"
:class="{ active: postId == item.id }"
@click="handleCellClick(item)"
>
<text>{{ item.label }}</text>
<template v-if="postId == item.id">
<TIcon name="icon-check" color="#0E66FB" />
</template>
</view>
</view>
<view class="flex justify-between p-[32rpx] gap-[32rpx]">
<view class="flex-1">
<u-button class="btn" type="primary" plain text="重置"></u-button>
</view>
<view class="flex-1">
<u-button class="btn" type="primary" text="确定"></u-button>
</view>
</view>
</view>
</u-dropdown-item>
<u-dropdown-item title="组织">
<view class="bg-white">
<DropdownPicker :dropdownItem="dropdownMenuList" />
<view class="flex justify-between p-[32rpx] gap-[32rpx]">
<view class="flex-1">
<u-button class="btn" type="primary" plain text="重置"></u-button>
</view>
<view class="flex-1">
<u-button class="btn" type="primary" text="确定"></u-button>
</view>
</view>
</view>
</u-dropdown-item>
</u-dropdown>
<view class="flex-1 mt-3 px-[32rpx] overflow-auto bg-[#FAFAFE]">
<z-paging
ref="paging"
v-model="dataList"
@query="queryList"
:fixed="false"
height="100%"
>
<template v-for="(item, index) in dataList" :key="`unique_${index}`">
<telesale-card
v-if="postId == 5"
:item="item"
@handleCardClick="handleCardClick"
/>
<recruitsale-card
v-if="postId == 6"
:item="item"
@handleCardClick="handleCardClick"
/>
</template>
</z-paging>
</view>
<!-- <DropdownPicker :dropdownItem="dropdownMenuList" /> -->
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import positionTabs from './components/position-tabs.vue'
import DaDropdown from '@/components/da-dropdown/index.vue'
import DropdownPicker from './components/orga-picker.vue'
import telesaleCard from './components/telesale-card.vue'
import recruitsaleCard from './components/recruitsale-card.vue'
import { useZPaging } from '@/hooks/useZPaging'
import { apiTeleClueList } from '@/api/clue'
const positionList = [
{
label: '电销老师',
id: 5,
value: 5
},
{
label: '招生老师',
id: 6,
value: 6
}
]
const postId = ref()
const handleCellClick = (item: any) => {
const { id } = item
postId.value = id
}
const dropdownMenuList = [
{
label: '级联X1',
value: '1',
children: [
{
label: '级联X11',
value: '11',
children: [
{ label: '级联X111', value: '111' },
{ label: '级联X122', value: '122' }
]
},
{ label: '级联X12', value: '12' }
]
},
{
label: '级联X2',
value: '2',
children: [
{ label: '级联X21', value: '21' },
{ label: '级联X22', value: '22' }
]
}
]
const queryParams = ref({
likeWork: ''
})
const dataList = ref([])
const { paging, queryList, refresh, changeApi, setParams } = useZPaging(
queryParams.value,
apiTeleClueList,
() => {}
)
const handleCardClick = (type: string, item: any) => {
const { id } = item
switch (type) {
case 'telesale':
case 'recruitsale':
uni.navigateTo({
url: '/bundle/pages/clue-list/index?id=' + id + '&type=' + type
})
break
default:
break
}
}
</script>
<style scoped lang="scss">
.cell {
display: flex;
justify-content: space-between;
align-items: center;
&.active {
color: $blue-1;
}
}
:deep(.btn) {
button {
@apply h-[72rpx] rounded-[12rpx];
}
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<view class="px-[32rpx]">
<card title="线索转化情况分析">
<view class="px-[32rpx]">
<view
class="flex flex-col gap-[16rpx] mb-[24rpx]"
v-for="(item, index) in data"
:key="`unique_${index}`"
>
<text class="text-gray5">{{ item.label }}</text>
<u-line-progress
:percentage="item.value"
activeColor="#5783FE"
shape="square"
></u-line-progress>
</view>
</view>
</card>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import card from '../../card.vue'
const data = ref([
{ label: '待领取', value: 28 },
{ label: '转化中', value: 12 },
{ label: '已添加', value: 53 },
{ label: '异常待处理', value: 56 },
{ label: '已成交', value: 12 },
{ label: '已战败', value: 100 }
])
</script>
<style scoped></style>

View File

@ -0,0 +1,39 @@
<template>
<view class="px-[32rpx]">
<card v-for="(item, index) in data" :key="`unique_${index}`" :title="item.name">
<view class="flex bg-gray3 mx-[24rpx] mb-[24rpx]">
<view
v-for="(itemy, indey) in item.children"
:key="indey"
class="flex-1 flex flex-col gap-[12rpx] justify-center items-center py-[12rpx]"
>
<text class="text-muted">{{ itemy.label }}</text>
<text class="font-bold">{{ itemy.value }}</text>
</view>
</view>
</card>
</view>
</template>
<script setup lang="ts">
import card from '../../card.vue'
const data = [
{
name: '湛江团队',
children: [
{ label: '线索', value: '52个' },
{ label: '成交客户', value: '52个' },
{ label: '转化率', value: '15%' }
]
},
{
name: '广州团队',
children: [
{ label: '线索', value: '52个' },
{ label: '成交客户', value: '52个' },
{ label: '转化率', value: '15%' }
]
}
]
</script>
<style scoped></style>

View File

@ -0,0 +1,31 @@
<template>
<view class="flex flex-col gap-[24rpx] px-[32rpx]">
<card title="数据简报">
<view class="flex flex-wrap">
<view
class="flex flex-col w-1/3 justify-center items-center gap-[12rpx] mb-[20rpx]"
v-for="(item, index) in data"
:key="`unique_${index}`"
>
<text class="text-muted">{{ item.label }}</text>
<text class="font-bold text-[40rpx]">{{ item.value }}</text>
</view>
</view>
</card>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import card from '../../card.vue'
const data = ref([
{ label: '新增跟进', value: 368 },
{ label: '新增客户', value: 368 },
{ label: '成交客户', value: 368 },
{ label: '转化中客户', value: 368 },
{ label: '异常待处理', value: 368 },
{ label: '战败客户', value: 368 }
])
</script>
<style scoped></style>

View File

@ -0,0 +1,101 @@
<template>
<view class="px-[32rpx]">
<card>
<u-tabs
:list="tabs"
:scrollable="false"
:activeStyle="{
color: '#303133',
fontWeight: 'bold',
transform: 'scale(1.05)'
}"
:inactiveStyle="{
color: '#606266',
transform: 'scale(1)'
}"
itemStyle="padding-left: 15px; padding-right: 15px; height: 34px;flex:0!important; "
lineWidth="32"
lineColor="#0E66FB"
@change="handleChangeTab"
></u-tabs>
<TTable :columns="columns" :data="data">
<template #index="scope">
<text class="px-[16rpx] py-[6rpx] rounded-[4px]" :style="rankStyle(scope.row)">
{{ scope.row }}
</text>
</template>
</TTable>
<view
class="flex justify-center py-[20rpx] border-t border-solid border-border"
@click="handleMore"
>
<text>查看更多</text>
</view>
</card>
</view>
</template>
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import card from '../../card.vue'
import { computed } from 'vue'
const activeTab = ref(1)
const tabs = shallowRef([
{ name: '电销TOP5', value: 1 },
{ name: '招生TOP5', value: 2 }
])
const totalLabel = computed(() => (activeTab.value == 1 ? '意向' : '成交'))
const handleChangeTab = item => {
activeTab.value = item.value
columns.value = generateColumns()
}
const generateColumns = () => {
return [
{ name: 'index', label: '排名', slot: true },
{ name: 'name', label: '姓名' },
{ name: 'total', label: totalLabel.value + '客户数', width: 120, align: 'right' }
]
}
const columns = ref(generateColumns())
const data = [
{
index: 1,
name: '王小虎1789789789789789',
total: 100
},
{
index: 2,
name: '王小虎2',
total: 20
},
{
index: 3,
name: '王小虎2',
total: 20
},
{
index: 4,
name: '王小虎2',
total: 20
}
]
const rankStyle = computed(() => row => {
const styleMap: Record<number, string> = {
'1': '#FCAE3C',
'2': '#BCC1D8',
'3': '#EEB286'
}
return {
color: row >= 4 ? '#3d3d3d' : '#fff',
background: styleMap[row]
}
})
const handleMore = () => {
uni.navigateTo({
url: '/bundle/pages/rank-list/index'
})
}
</script>
<style scoped></style>

View File

@ -0,0 +1,16 @@
<template>
<view class="flex flex-col gap-[24rpx] mb-[20rpx]">
<data-overview />
<converted-overview />
<rank />
<clue-status />
</view>
</template>
<script setup lang="ts">
import dataOverview from './components/data-overview.vue'
import convertedOverview from './components/converted-overview.vue'
import rank from './components/rank.vue'
import clueStatus from './components/clue-status.vue'
</script>
<style scoped></style>

View File

@ -17,3 +17,7 @@ export enum stateEnum {
NO_EXIST = 1, //账号不存在
UN_PASS = 2 //账号未通过
}
export enum AdminTabEnum {
TEAM = 1,
PERSONALLY = 2
}

View File

@ -8,7 +8,7 @@ import contractActive from '@/static/images/contract-active.png'
import contractInActive from '@/static/images/contract-inactive.png'
import profileInActive from '@/static/images/profile-inactive.png'
import profileActive from '@/static/images/profile-active.png'
import { type TabBarProp } from '@/stores/tabbar'
import { useTabBarStore, type TabBarProp } from '@/stores/tabbar'
import { useUserStore } from '@/stores/user'
import cache from '@/utils/cache'
import { ROLEINDEX } from '@/enums/cacheEnums'
@ -69,6 +69,24 @@ export function useRoleData() {
activeIcon: profileActive
}
]
},
{
name: '主账号',
ids: [],
tabBarList: [
{
text: '首页',
inactiveIcon: order,
activeIcon: orderActive,
path: '/pages/admin/home/index'
},
{
text: '个人中心',
path: '/pages/admin/my/index',
inactiveIcon: profileInActive,
activeIcon: profileActive
}
]
}
]
return {
@ -95,3 +113,25 @@ export function useJudgeApprentice() {
if (typeof userInfo.isApprentice == undefined) return false
return userInfo?.isApprentice === ApprenticeEnum.NO ? false : true
}
export const setNextRoute = () => {
const roles = useRoleData().roles
const userStore = useUserStore()
const tabBarStore = useTabBarStore()
const { userInfo } = userStore
const ind = cache.get(ROLEINDEX) || 0
const obj = roles[ind]
if (userInfo.postIds == 0 && obj.ids.length == 0) {
tabBarStore.setTabBarList(obj.tabBarList)
uni.reLaunch({
url: obj.tabBarList[0].path
})
} else {
const obj = roles.find(role => {
return role.ids.includes(userInfo?.roles[ind]?.id)
})
tabBarStore.setTabBarList(obj?.tabBarList)
uni.reLaunch({
url: obj?.tabBarList[0].path || ''
})
}
}

View File

@ -1,5 +1,12 @@
{
"pages": [
{
"path": "pages/admin/home/index",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/index/index",
"style": {
@ -50,6 +57,13 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/admin/my/index",
"style": {
"navigationBarTitleText": ""
},
"auth": true
},
{
"path": "pages/salesman/contract/index",
@ -132,6 +146,18 @@
{
"root": "bundle",
"pages": [
{
"path": "pages/rank-list/index",
"style": {
"navigationBarTitleText": "业绩排行榜"
}
},
{
"path": "pages/clue-list/index",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "pages/clue/detail",
"style": {

View File

@ -0,0 +1,34 @@
<template>
<TContainer>
<u-tabs
:list="tabs"
:scrollable="false"
:itemStyle="{ height: '44px', flex: 1 }"
:activeStyle="{ color: '#0E66FB' }"
lineWidth="32"
lineColor="#0E66FB"
@change="handleChangeTab"
></u-tabs>
<view class="flex-1 bg-gray3 pt-[32rpx]">
<admin-team v-if="activeTab === AdminTabEnum.TEAM" />
<personally v-if="activeTab === AdminTabEnum.PERSONALLY" />
</view>
</TContainer>
</template>
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { AdminTabEnum } from '@/enums'
import adminTeam from '@/components/widgets/admin/team/index.vue'
import personally from '@/components/widgets/admin/personally/index.vue'
const activeTab = ref(AdminTabEnum.PERSONALLY)
const tabs = shallowRef([
{ name: '团队', value: AdminTabEnum.TEAM },
{ name: '个人', value: AdminTabEnum.PERSONALLY }
])
const handleChangeTab = item => {
activeTab.value = item.value
}
</script>
<style scoped></style>

View File

@ -0,0 +1,6 @@
<template>
<TContainer></TContainer>
</template>
<script setup lang="ts"></script>
<style scoped></style>

View File

@ -3,33 +3,20 @@
</template>
<script setup lang="ts">
import { ROLEINDEX } from '@/enums/cacheEnums'
import { useRoleData } from '@/hooks/useRoleData'
import { useTabBarStore } from '@/stores/tabbar'
import { useUserStore } from '@/stores/user'
import cache from '@/utils/cache'
import { onLoad } from '@dcloudio/uni-app'
import { setNextRoute } from '@/hooks/useRoleData'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const { roles } = useRoleData()
const tabBarStore = useTabBarStore()
const setNextRoute = () => {
const ind = cache.get(ROLEINDEX) || 0
const { userInfo } = userStore
const obj = roles.find(role => {
return role.ids.includes(userInfo.roles[ind].id)
})
tabBarStore.setTabBarList(obj?.tabBarList)
uni.reLaunch({
url: obj?.tabBarList[0].path || ''
})
}
onLoad(() => {
if (userStore.isLogin) {
// =>
setNextRoute()
} else {
// uni.navigateTo({
// url: '/bundle/pages/login/login'
// })
}
})
</script>

View File

@ -1,8 +1,8 @@
@font-face {
font-family: 'iconfont'; /* Project id 4837700 */
src: url('//at.alicdn.com/t/c/font_4837700_mvrnof6x61s.woff2?t=1740644911077') format('woff2'),
url('//at.alicdn.com/t/c/font_4837700_mvrnof6x61s.woff?t=1740644911077') format('woff'),
url('//at.alicdn.com/t/c/font_4837700_mvrnof6x61s.ttf?t=1740644911077') format('truetype');
src: url('//at.alicdn.com/t/c/font_4837700_x1g80sb5n5.woff2?t=1741073935675') format('woff2'),
url('//at.alicdn.com/t/c/font_4837700_x1g80sb5n5.woff?t=1741073935675') format('woff'),
url('//at.alicdn.com/t/c/font_4837700_x1g80sb5n5.ttf?t=1741073935675') format('truetype');
}
.iconfont {
@ -13,6 +13,14 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-filter:before {
content: '\e672';
}
.icon-check:before {
content: '\e677';
}
.icon-clear:before {
content: '\e601';
}

View File

@ -14,6 +14,7 @@ export default {
inactiveColor: '#ececec',
percentage: 0,
showText: true,
height: 12
height: 12,
shape: 'circle'
}
}

View File

@ -25,6 +25,11 @@ export const props = defineMixin({
height: {
type: [String, Number],
default: () => defProps.lineProgress.height
},
// 进度条形状
shape: {
type: String,
default: () => defProps.lineProgress.shape
}
}
})

View File

@ -1,149 +1,146 @@
<template>
<view
class="u-line-progress"
:style="[addStyle(customStyle)]"
>
<view
class="u-line-progress__background"
ref="u-line-progress__background"
:style="[{
backgroundColor: inactiveColor,
height: addUnit(height),
}]"
>
</view>
<view
class="u-line-progress__line"
:style="[progressStyle]"
>
<slot>
<text v-if="showText && percentage >= 10" class="u-line-progress__text">{{innserPercentage + '%'}}</text>
</slot>
</view>
</view>
<view class="u-line-progress" :style="[addStyle(customStyle)]">
<view
class="u-line-progress__background"
ref="u-line-progress__background"
:style="[
{
backgroundColor: inactiveColor,
height: addUnit(height),
borderRadius: shape == 'circle' ? '100px' : 'none'
}
]"
></view>
<view class="u-line-progress__line" :style="[progressStyle]">
<slot>
<text v-if="showText && percentage >= 10" class="u-line-progress__text">
{{ innserPercentage + '%' }}
</text>
</slot>
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, addStyle, sleep, range } from '../../libs/function/index';
// #ifdef APP-NVUE
const dom = uni.requireNativePlugin('dom')
// #endif
/**
* lineProgress 线型进度条
* @description 展示操作或任务的当前进度比如上传文件是一个线形的进度条
* @tutorial https://ijry.github.io/uview-plus/components/lineProgress.html
* @property {String} activeColor 激活部分的颜色 ( 默认 '#19be6b' )
* @property {String} inactiveColor 背景色 ( 默认 '#ececec' )
* @property {String | Number} percentage 进度百分比数值 ( 默认 0 )
* @property {Boolean} showText 是否在进度条内部显示百分比的值 ( 默认 true )
* @property {String | Number} height 进度条的高度单位px ( 默认 12 )
*
* @example <u-line-progress :percent="70" :show-percent="true"></u-line-progress>
*/
export default {
name: "u-line-progress",
mixins: [mpMixin, mixin, props],
data() {
return {
lineWidth: 0,
}
},
watch: {
percentage(n) {
this.resizeProgressWidth()
}
},
computed: {
progressStyle() {
let style = {}
style.width = this.lineWidth
style.backgroundColor = this.activeColor
style.height = addUnit(this.height)
return style
},
innserPercentage() {
// 0-100
return range(0, 100, this.percentage)
}
},
mounted() {
this.init()
},
methods: {
addStyle,
addUnit,
init() {
sleep(20).then(() => {
this.resizeProgressWidth()
})
},
getProgressWidth() {
// #ifndef APP-NVUE
return this.$uGetRect('.u-line-progress__background')
// #endif
import { props } from './props'
import { mpMixin } from '../../libs/mixin/mpMixin'
import { mixin } from '../../libs/mixin/mixin'
import { addUnit, addStyle, sleep, range } from '../../libs/function/index'
// #ifdef APP-NVUE
const dom = uni.requireNativePlugin('dom')
// #endif
/**
* lineProgress 线型进度条
* @description 展示操作或任务的当前进度比如上传文件是一个线形的进度条
* @tutorial https://ijry.github.io/uview-plus/components/lineProgress.html
* @property {String} activeColor 激活部分的颜色 ( 默认 '#19be6b' )
* @property {String} inactiveColor 背景色 ( 默认 '#ececec' )
* @property {String | Number} percentage 进度百分比数值 ( 默认 0 )
* @property {Boolean} showText 是否在进度条内部显示百分比的值 ( 默认 true )
* @property {String | Number} height 进度条的高度单位px ( 默认 12 )
*
* @example <u-line-progress :percent="70" :show-percent="true"></u-line-progress>
*/
export default {
name: 'u-line-progress',
mixins: [mpMixin, mixin, props],
data() {
return {
lineWidth: 0
}
},
watch: {
percentage(n) {
this.resizeProgressWidth()
}
},
computed: {
progressStyle() {
let style = {}
style.width = this.lineWidth
style.backgroundColor = this.activeColor
style.height = addUnit(this.height)
style.borderRadius = this.shape == 'circle' ? '100px' : 'none'
return style
},
innserPercentage() {
// 0-100
return range(0, 100, this.percentage)
}
},
mounted() {
this.init()
},
methods: {
addStyle,
addUnit,
init() {
sleep(20).then(() => {
this.resizeProgressWidth()
})
},
getProgressWidth() {
// #ifndef APP-NVUE
return this.$uGetRect('.u-line-progress__background')
// #endif
// #ifdef APP-NVUE
// promise
return new Promise(resolve => {
dom.getComponentRect(this.$refs['u-line-progress__background'], (res) => {
resolve(res.size)
})
})
// #endif
},
resizeProgressWidth() {
this.getProgressWidth().then(size => {
const {
width
} = size
// percentage
this.lineWidth = width * this.innserPercentage / 100 + 'px'
})
}
}
}
// #ifdef APP-NVUE
// promise
return new Promise(resolve => {
dom.getComponentRect(this.$refs['u-line-progress__background'], res => {
resolve(res.size)
})
})
// #endif
},
resizeProgressWidth() {
this.getProgressWidth().then(size => {
const { width } = size
// percentage
this.lineWidth = (width * this.innserPercentage) / 100 + 'px'
})
}
}
}
</script>
<style lang="scss" scoped>
@import "../../libs/css/components.scss";
@import '../../libs/css/components.scss';
.u-line-progress {
align-items: stretch;
position: relative;
@include flex(row);
flex: 1;
overflow: hidden;
border-radius: 100px;
.u-line-progress {
align-items: stretch;
position: relative;
@include flex(row);
flex: 1;
overflow: hidden;
// border-radius: 100px;
&__background {
background-color: #ececec;
border-radius: 100px;
flex: 1;
}
&__background {
background-color: #ececec;
// border-radius: 100px;
flex: 1;
}
&__line {
position: absolute;
top: 0;
left: 0;
bottom: 0;
align-items: center;
@include flex(row);
color: #ffffff;
border-radius: 100px;
transition: width 0.5s ease;
justify-content: flex-end;
}
&__line {
position: absolute;
top: 0;
left: 0;
bottom: 0;
align-items: center;
@include flex(row);
color: #ffffff;
// border-radius: 100px;
transition: width 0.5s ease;
justify-content: flex-end;
}
&__text {
font-size: 10px;
align-items: center;
text-align: right;
color: #FFFFFF;
margin-right: 5px;
transform: scale(0.9);
}
}
&__text {
font-size: 10px;
align-items: center;
text-align: right;
color: #ffffff;
margin-right: 5px;
transform: scale(0.9);
}
}
</style>

View File

@ -29,6 +29,7 @@ module.exports = {
gray2: '#7C7E82',
gray3: '#F8F8F8',
gray4: '#999',
gray5: '#6E7079',
lightblack: '#3D3D3D',
border: '#F1F1F1',
border2: '#F3F3F3',