feat:昵称重复、显示分销人分销tree
This commit is contained in:
1 parent
c1411e0fde
commit
ee06dbaf9b
9 files changed
+1243
-268
No files matched your search
@@ -25,12 +25,15 @@
|
||||
"lint:lint-staged": "lint-staged -c "
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "3.1.3",
|
||||
"@element-plus/icons-vue": "^2.1.0",
|
||||
"@form-create/designer": "^3.2.6",
|
||||
"@form-create/element-ui": "^3.2.11",
|
||||
"@iconify/iconify": "^3.1.1",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@videojs-player/vue": "^1.0.0",
|
||||
"@vue-flow/controls": "1.1.3",
|
||||
"@vue-flow/core": "1.48.2",
|
||||
"@vueuse/core": "^10.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.10",
|
||||
|
||||
Generated
+48
@@ -17,6 +17,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dagrejs/dagre':
|
||||
specifier: 3.1.3
|
||||
version: 3.1.3
|
||||
'@element-plus/icons-vue':
|
||||
specifier: ^2.1.0
|
||||
version: 2.3.2(vue@3.5.12(typescript@5.3.3))
|
||||
@@ -35,6 +38,12 @@ importers:
|
||||
'@videojs-player/vue':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(@types/video.js@7.3.58)(video.js@7.21.7)(vue@3.5.12(typescript@5.3.3))
|
||||
'@vue-flow/controls':
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3(@vue-flow/core@1.48.2(vue@3.5.12(typescript@5.3.3)))(vue@3.5.12(typescript@5.3.3))
|
||||
'@vue-flow/core':
|
||||
specifier: 1.48.2
|
||||
version: 1.48.2(vue@3.5.12(typescript@5.3.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^10.9.0
|
||||
version: 10.11.1(vue@3.5.12(typescript@5.3.3))
|
||||
@@ -1104,6 +1113,12 @@ packages:
|
||||
resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@dagrejs/dagre@3.1.3':
|
||||
resolution: {integrity: sha512-X4aUTwOReZvthaZWOVHqlJ52/ZdhZFEM4+Lh/uR4XooUSLE6p71bcy7MPGzYnNUcNh+nj+i/qJQpCMIdQnSmrg==}
|
||||
|
||||
'@dagrejs/graphlib@4.0.6':
|
||||
resolution: {integrity: sha512-Hjv7hbfmSBMP4nVXMFt4gusqWWHghQF0qNT4V5KHo4Jatvd+2L5VHKFnnIjXVuDnaHIRz7ZuHSnlf0RuZkOwWA==}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1':
|
||||
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
|
||||
|
||||
@@ -2324,6 +2339,17 @@ packages:
|
||||
'@volar/typescript@1.11.1':
|
||||
resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==}
|
||||
|
||||
'@vue-flow/controls@1.1.3':
|
||||
resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==}
|
||||
peerDependencies:
|
||||
'@vue-flow/core': ^1.23.0
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue-flow/core@1.48.2':
|
||||
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue/babel-helper-vue-transform-on@1.5.0':
|
||||
resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
|
||||
|
||||
@@ -7269,6 +7295,12 @@ snapshots:
|
||||
|
||||
'@ctrl/tinycolor@4.2.0': {}
|
||||
|
||||
'@dagrejs/dagre@3.1.3':
|
||||
dependencies:
|
||||
'@dagrejs/graphlib': 4.0.6
|
||||
|
||||
'@dagrejs/graphlib@4.0.6': {}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1': {}
|
||||
|
||||
'@element-plus/icons-vue@2.3.2(vue@3.5.12(typescript@5.3.3))':
|
||||
@@ -8568,6 +8600,22 @@ snapshots:
|
||||
'@volar/language-core': 1.11.1
|
||||
path-browserify: 1.0.1
|
||||
|
||||
'@vue-flow/controls@1.1.3(@vue-flow/core@1.48.2(vue@3.5.12(typescript@5.3.3)))(vue@3.5.12(typescript@5.3.3))':
|
||||
dependencies:
|
||||
'@vue-flow/core': 1.48.2(vue@3.5.12(typescript@5.3.3))
|
||||
vue: 3.5.12(typescript@5.3.3)
|
||||
|
||||
'@vue-flow/core@1.48.2(vue@3.5.12(typescript@5.3.3))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.12(typescript@5.3.3))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.12(typescript@5.3.3)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue/babel-helper-vue-transform-on@1.5.0': {}
|
||||
|
||||
'@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.7)':
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export interface MemberSummary {
|
||||
id: number
|
||||
userCode?: string | null
|
||||
nickname?: string | null
|
||||
avatar?: string | null
|
||||
memberExists: boolean
|
||||
}
|
||||
|
||||
export interface UserInviterVO extends MemberSummary {
|
||||
bindUserTime?: string | number | null
|
||||
}
|
||||
|
||||
export interface BrokerageChildVO extends MemberSummary {
|
||||
status?: number | null
|
||||
bindUserId: number
|
||||
bindUserTime?: string | number | null
|
||||
directChildCount: number
|
||||
}
|
||||
|
||||
export interface UserVO {
|
||||
id: number
|
||||
userCode?: string | null
|
||||
inviter?: UserInviterVO | null
|
||||
avatar: string | undefined
|
||||
birthday: number | undefined
|
||||
createTime: number | undefined
|
||||
@@ -67,6 +88,17 @@ export const getUser = async (id: number) => {
|
||||
return await request.get({ url: `/member/user/get?id=` + id })
|
||||
}
|
||||
|
||||
export const getBrokerageChildrenPage = (params: {
|
||||
bindUserId: number
|
||||
pageNo: number
|
||||
pageSize: number
|
||||
}) => {
|
||||
return request.get<{ list: BrokerageChildVO[]; total: number }>({
|
||||
url: '/member/user/brokerage-children-page',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 修改会员用户
|
||||
export const updateUser = async (data: UserVO) => {
|
||||
return await request.put({ url: `/member/user/update`, data })
|
||||
|
||||
@@ -2,45 +2,27 @@
|
||||
<doc-alert title="上传下载" url="https://doc.iocoder.cn/file/" />
|
||||
<!-- 搜索 -->
|
||||
<ContentWrap>
|
||||
<el-form
|
||||
class="-mb-15px"
|
||||
:model="queryParams"
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="68px">
|
||||
<el-form-item label="文件路径" prop="path">
|
||||
<el-input
|
||||
v-model="queryParams.path"
|
||||
placeholder="请输入文件路径"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
<el-input v-model="queryParams.path" placeholder="请输入文件路径" clearable @keyup.enter="handleQuery"
|
||||
class="!w-240px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="文件类型" prop="type" width="80">
|
||||
<el-input
|
||||
v-model="queryParams.type"
|
||||
placeholder="请输入文件类型"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
class="!w-240px"
|
||||
/>
|
||||
<el-input v-model="queryParams.type" placeholder="请输入文件类型" clearable @keyup.enter="handleQuery"
|
||||
class="!w-240px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="daterange"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
/>
|
||||
<el-date-picker v-model="queryParams.createTime" value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
|
||||
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
|
||||
<el-button @click="handleQuery">
|
||||
<Icon icon="ep:search" class="mr-5px" /> 搜索
|
||||
</el-button>
|
||||
<el-button @click="resetQuery">
|
||||
<Icon icon="ep:refresh" class="mr-5px" /> 重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openForm">
|
||||
<Icon icon="ep:upload" class="mr-5px" /> 上传文件
|
||||
</el-button>
|
||||
@@ -54,68 +36,32 @@
|
||||
<el-table-column label="文件名" align="center" prop="name" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="文件路径" align="center" prop="path" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="URL" align="center" prop="url" :show-overflow-tooltip="true" />
|
||||
<el-table-column
|
||||
label="文件大小"
|
||||
align="center"
|
||||
prop="size"
|
||||
width="120"
|
||||
:formatter="fileSizeFormatter"
|
||||
/>
|
||||
<el-table-column label="文件大小" align="center" prop="size" width="120" :formatter="fileSizeFormatter" />
|
||||
<el-table-column label="文件类型" align="center" prop="type" width="180px" />
|
||||
<el-table-column label="文件内容" align="center" prop="url" width="110px">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.type.includes('image')"
|
||||
class="h-80px w-80px"
|
||||
lazy
|
||||
:src="row.url"
|
||||
:preview-src-list="[row.url]"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
/>
|
||||
<el-link
|
||||
v-else-if="row.type.includes('pdf')"
|
||||
type="primary"
|
||||
:href="row.url"
|
||||
:underline="false"
|
||||
target="_blank"
|
||||
>预览</el-link
|
||||
>
|
||||
<el-link v-else type="primary" download :href="row.url" :underline="false" target="_blank"
|
||||
>下载</el-link
|
||||
>
|
||||
<el-image v-if="row.type.includes('image')" class="h-80px w-80px" lazy :src="row.url"
|
||||
:preview-src-list="[row.url]" preview-teleported fit="cover" />
|
||||
<el-link v-else-if="row.type.includes('pdf')" type="primary" :href="row.url" :underline="false"
|
||||
target="_blank">预览</el-link>
|
||||
<el-link v-else type="primary" download :href="row.url" :underline="false" target="_blank">下载</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="上传时间"
|
||||
align="center"
|
||||
prop="createTime"
|
||||
width="180"
|
||||
:formatter="dateFormatter"
|
||||
/>
|
||||
<el-table-column label="上传时间" align="center" prop="createTime" width="180" :formatter="dateFormatter" />
|
||||
<el-table-column label="操作" align="center">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" @click="copyToClipboard(scope.row.url)">
|
||||
复制链接
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="danger"
|
||||
@click="handleDelete(scope.row.id)"
|
||||
v-hasPermi="['infra:file:delete']"
|
||||
>
|
||||
<el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['infra:file:delete']">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNo"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList" />
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
@@ -192,7 +138,7 @@ const handleDelete = async (id: number) => {
|
||||
message.success(t('common.delSuccess'))
|
||||
// 刷新列表
|
||||
await getList()
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
/** 初始化 **/
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<section class="brokerage-tree" aria-label="会员分销关系">
|
||||
<div class="brokerage-tree__toolbar">
|
||||
<div>
|
||||
<h3>分销关系</h3>
|
||||
<p>查看直接邀请人,逐层展开下级会员</p>
|
||||
</div>
|
||||
<div class="brokerage-tree__tools">
|
||||
<span class="brokerage-tree__legend"><i aria-hidden="true"></i>当前会员</span>
|
||||
<el-button :loading="refreshing" size="small" @click="refresh">
|
||||
<el-icon class="mr-4px" aria-hidden="true"><RefreshIcon /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="refreshError" :title="refreshError" type="error" :closable="false" show-icon />
|
||||
<div v-if="root" class="brokerage-tree__canvas">
|
||||
<VueFlow
|
||||
:id="flowId"
|
||||
:nodes="layout.nodes"
|
||||
:edges="layout.edges"
|
||||
:nodes-draggable="false"
|
||||
:nodes-connectable="false"
|
||||
:edges-updatable="false"
|
||||
:elements-selectable="false"
|
||||
:connection-mode="ConnectionMode.Strict"
|
||||
:delete-key-code="null"
|
||||
:min-zoom="0.1"
|
||||
:max-zoom="1.5"
|
||||
:zoom-on-double-click="false"
|
||||
>
|
||||
<template #node-member="{ data }">
|
||||
<div
|
||||
class="brokerage-tree__node"
|
||||
:class="{ 'brokerage-tree__node--current': data.role === 'current' }"
|
||||
>
|
||||
<Handle
|
||||
v-if="data.role !== 'inviter'"
|
||||
id="in"
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
:connectable="false"
|
||||
/>
|
||||
<div class="brokerage-tree__node-heading">
|
||||
<span>{{ roleLabels[data.role] }}</span>
|
||||
<el-tag
|
||||
v-if="data.member.status === CommonStatusEnum.DISABLE"
|
||||
size="small"
|
||||
type="info"
|
||||
>已禁用</el-tag
|
||||
>
|
||||
</div>
|
||||
<div class="brokerage-tree__identity" :title="data.member.nickname || '未设置昵称'">
|
||||
<el-avatar :src="data.member.avatar || undefined" :size="36" aria-hidden="true">
|
||||
<el-icon><UserIcon /></el-icon>
|
||||
</el-avatar>
|
||||
<div>
|
||||
<strong>{{
|
||||
data.member.memberExists ? data.member.nickname || '未设置昵称' : '会员资料缺失'
|
||||
}}</strong>
|
||||
<span>ID {{ data.member.userCode || data.member.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brokerage-tree__meta" aria-live="polite" :title="bindingTime(data)">
|
||||
<span v-if="data.branch?.error" class="brokerage-tree__error">{{
|
||||
data.branch.error
|
||||
}}</span>
|
||||
<span
|
||||
v-else-if="data.branch?.warning"
|
||||
class="brokerage-tree__warning"
|
||||
:title="data.branch.warning"
|
||||
>关系异常,请刷新核对</span
|
||||
>
|
||||
<span v-else-if="data.role === 'inviter'">邀请了当前会员</span>
|
||||
<span v-else-if="data.branch?.loading && data.branch.pageNo === 0"
|
||||
>正在加载直属下级…</span
|
||||
>
|
||||
<span v-else>直属下级 {{ data.branch?.total || 0 }} 人</span>
|
||||
<el-tooltip v-if="bindingTime(data)" :content="bindingTime(data)" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="brokerage-tree__time nodrag nopan"
|
||||
:aria-label="bindingTime(data)"
|
||||
>
|
||||
<el-icon aria-hidden="true"><ClockIcon /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="brokerage-tree__actions nodrag nopan">
|
||||
<el-button
|
||||
v-if="data.role !== 'current'"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!data.member.memberExists"
|
||||
@click="openDetail(data.member.id)"
|
||||
>查看详情</el-button
|
||||
>
|
||||
<template v-if="data.branch">
|
||||
<el-button
|
||||
v-if="data.branch.error"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="data.branch.loading"
|
||||
@click="loadChildren(data.member.id)"
|
||||
>重试</el-button
|
||||
>
|
||||
<el-button
|
||||
v-else-if="data.branch.total > 0 || data.branch.loading"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:aria-expanded="data.branch.expanded"
|
||||
:loading="data.branch.loading && data.branch.pageNo === 0"
|
||||
@click="toggleBranch(data.member.id)"
|
||||
>
|
||||
{{ data.branch.expanded ? '收起下级' : '展开下级' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="
|
||||
data.branch.expanded &&
|
||||
data.branch.pageNo > 0 &&
|
||||
hasMore(data.branch) &&
|
||||
!data.branch.error
|
||||
"
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="data.branch.loading"
|
||||
@click="loadChildren(data.member.id)"
|
||||
>加载更多</el-button
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
<Handle
|
||||
id="out"
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
:connectable="false"
|
||||
:style="{
|
||||
visibility: data.role === 'inviter' || data.branch?.total ? 'visible' : 'hidden'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<Controls position="bottom-right" :show-interactive="false">
|
||||
<template #control-zoom-in>
|
||||
<button
|
||||
type="button"
|
||||
class="vue-flow__controls-button"
|
||||
aria-label="放大关系树"
|
||||
title="放大"
|
||||
@click="zoomIn()"
|
||||
><el-icon aria-hidden="true"><PlusIcon /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
<template #control-zoom-out>
|
||||
<button
|
||||
type="button"
|
||||
class="vue-flow__controls-button"
|
||||
aria-label="缩小关系树"
|
||||
title="缩小"
|
||||
@click="zoomOut()"
|
||||
><el-icon aria-hidden="true"><MinusIcon /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
<template #control-fit-view>
|
||||
<button
|
||||
type="button"
|
||||
class="vue-flow__controls-button"
|
||||
aria-label="关系树适应画布"
|
||||
title="适应画布"
|
||||
@click="fitTree"
|
||||
><el-icon aria-hidden="true"><FullScreenIcon /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
</Controls>
|
||||
</VueFlow>
|
||||
</div>
|
||||
<el-empty v-else description="会员资料加载后可查看分销关系" />
|
||||
<p v-if="root" class="brokerage-tree__hint"
|
||||
>滚轮缩放,拖动画布移动。每个分支每次加载 20 人,悬停时钟查看绑定时间。</p
|
||||
>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, toRef, useId, watch } from 'vue'
|
||||
import {
|
||||
VueFlow,
|
||||
Handle,
|
||||
Position,
|
||||
MarkerType,
|
||||
ConnectionMode,
|
||||
useVueFlow,
|
||||
type Node,
|
||||
type Edge
|
||||
} from '@vue-flow/core'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import {
|
||||
User as UserIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Clock as ClockIcon,
|
||||
Plus as PlusIcon,
|
||||
Minus as MinusIcon,
|
||||
FullScreen as FullScreenIcon
|
||||
} from '@element-plus/icons-vue'
|
||||
import { graphlib, layout as layoutGraph } from '@dagrejs/dagre'
|
||||
import type { UserVO } from '@/api/member/user'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
import { formatDate } from '@/utils/formatTime'
|
||||
import { useBrokerageTree, type BrokerageTreeNode } from './useBrokerageTree'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import '@vue-flow/controls/dist/style.css'
|
||||
|
||||
const props = defineProps<{ user: UserVO | null; active: boolean }>()
|
||||
const { root, graph, refreshing, refreshError, hasMore, loadChildren, toggleBranch, refresh } =
|
||||
useBrokerageTree(toRef(props, 'user'))
|
||||
const flowId = `member-brokerage-${useId()}`
|
||||
const { fitView, zoomIn, zoomOut, onNodesInitialized } = useVueFlow({ id: flowId })
|
||||
const { push } = useRouter()
|
||||
const roleLabels = { inviter: '直接邀请人', current: '当前会员', child: '下级会员' }
|
||||
const NODE_WIDTH = 248
|
||||
const NODE_HEIGHT = 174
|
||||
|
||||
const layout = computed(() => {
|
||||
const tree = new graphlib.Graph()
|
||||
tree.setGraph({ rankdir: 'TB', nodesep: 28, ranksep: 64, marginx: 24, marginy: 24 })
|
||||
tree.setDefaultEdgeLabel(() => ({}))
|
||||
graph.value.nodes.forEach(({ member }) =>
|
||||
tree.setNode(String(member.id), { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||
)
|
||||
graph.value.edges.forEach(({ source, target }) => tree.setEdge(String(source), String(target)))
|
||||
layoutGraph(tree)
|
||||
const nodes: Node<BrokerageTreeNode>[] = graph.value.nodes.map((data) => {
|
||||
const position = tree.node(String(data.member.id))
|
||||
return {
|
||||
id: String(data.member.id),
|
||||
type: 'member',
|
||||
data,
|
||||
sourcePosition: Position.Bottom,
|
||||
targetPosition: Position.Top,
|
||||
style: { pointerEvents: 'all' },
|
||||
position: { x: position.x - NODE_WIDTH / 2, y: position.y - NODE_HEIGHT / 2 },
|
||||
ariaLabel: `${roleLabels[data.role]} ${data.member.nickname || data.member.id}`
|
||||
}
|
||||
})
|
||||
const edges: Edge[] = graph.value.edges.map(({ source, target, bindUserTime }) => ({
|
||||
id: `${source}-${target}`,
|
||||
source: String(source),
|
||||
target: String(target),
|
||||
sourceHandle: 'out',
|
||||
targetHandle: 'in',
|
||||
type: 'smoothstep',
|
||||
markerEnd: MarkerType.ArrowClosed,
|
||||
ariaLabel: `邀请关系${bindUserTime ? `,绑定于${formatDate(new Date(bindUserTime))}` : ''}`
|
||||
}))
|
||||
return { nodes, edges }
|
||||
})
|
||||
|
||||
function bindingTime(data: BrokerageTreeNode) {
|
||||
const value =
|
||||
data.role === 'current' ? root.value?.inviter?.bindUserTime : data.member.bindUserTime
|
||||
return value ? `绑定于 ${formatDate(new Date(value))}` : ''
|
||||
}
|
||||
|
||||
function openDetail(id: number) {
|
||||
push({ name: 'MemberUserDetail', params: { id } })
|
||||
}
|
||||
|
||||
async function fitTree() {
|
||||
await nextTick()
|
||||
if (props.active) await fitView({ padding: 0.18, maxZoom: 1, duration: 0 })
|
||||
}
|
||||
|
||||
onNodesInitialized(fitTree)
|
||||
watch(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
if (active) void fitTree()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.brokerage-tree {
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
&__toolbar,
|
||||
&__tools,
|
||||
&__legend,
|
||||
&__node-heading,
|
||||
&__identity,
|
||||
&__meta,
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
&__tools {
|
||||
gap: 16px;
|
||||
}
|
||||
&__legend {
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
&__legend i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid var(--el-color-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__canvas {
|
||||
height: 520px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
width: 248px;
|
||||
height: 174px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 12px;
|
||||
|
||||
&--current {
|
||||
border: 2px solid var(--el-color-primary);
|
||||
padding: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
&__node-heading {
|
||||
height: 20px;
|
||||
justify-content: space-between;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
&__node--current &__node-heading > span {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
&__identity {
|
||||
gap: 10px;
|
||||
}
|
||||
&__identity > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
&__identity strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
&__identity span {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
&__identity .el-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
&__meta {
|
||||
min-height: 20px;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
&__time {
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
&__error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
&__warning {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
&__actions {
|
||||
min-height: 24px;
|
||||
gap: 10px;
|
||||
}
|
||||
&__actions .el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
&__hint {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:deep(.vue-flow__handle) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border: 0;
|
||||
background: var(--el-border-color-darker);
|
||||
}
|
||||
:deep(.vue-flow__node) {
|
||||
cursor: default;
|
||||
}
|
||||
:deep(.vue-flow__edge-path) {
|
||||
stroke: var(--el-border-color-darker);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
:deep(.vue-flow__arrowhead polyline) {
|
||||
stroke: var(--el-border-color-darker);
|
||||
fill: var(--el-border-color-darker);
|
||||
}
|
||||
:deep(.vue-flow__controls) {
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.vue-flow__controls-button) {
|
||||
background: var(--el-bg-color-overlay);
|
||||
color: var(--el-text-color-primary);
|
||||
border-color: var(--el-border-color-light);
|
||||
}
|
||||
:deep(.vue-flow__controls-button:hover) {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
:deep(button:focus-visible),
|
||||
:deep(.vue-flow__node:focus-visible) {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@
|
||||
<template #header>
|
||||
<CardTitle title="账户明细" />
|
||||
</template>
|
||||
<el-tabs>
|
||||
<el-tabs v-model="activeDetailTab">
|
||||
<el-tab-pane label="喜欢我">
|
||||
<LikePage type="user" :memberId="id" />
|
||||
</el-tab-pane>
|
||||
@@ -73,6 +73,9 @@
|
||||
<el-tab-pane v-if="false" label="优惠劵" lazy>
|
||||
<UserCouponList :user-id="id" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="分销关系" name="brokerage" lazy>
|
||||
<UserBrokerageTree :user="user" :active="activeDetailTab === 'brokerage'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="推广用户" lazy>
|
||||
<UserBrokerageList :bind-user-id="id" />
|
||||
</el-tab-pane>
|
||||
@@ -85,6 +88,7 @@
|
||||
<UserForm ref="formRef" @success="getUserData(id)" />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
import * as WalletApi from '@/api/pay/wallet/balance'
|
||||
import * as UserApi from '@/api/member/user'
|
||||
import { useTagsViewStore } from '@/store/modules/tagsView'
|
||||
@@ -112,6 +116,9 @@ import LookWechatPage from "@/views/member/recent/cmp/look-wechat.vue"
|
||||
|
||||
defineOptions({ name: 'MemberDetail' })
|
||||
|
||||
const UserBrokerageTree = defineAsyncComponent(() => import('./UserBrokerageTree.vue'))
|
||||
const activeDetailTab = ref('0')
|
||||
|
||||
const loading = ref(true) // 加载中
|
||||
const user = ref<UserApi.UserVO>({} as UserApi.UserVO)
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch, type Ref } from 'vue'
|
||||
import * as UserApi from '@/api/member/user'
|
||||
import type { BrokerageChildVO, MemberSummary, UserVO } from '@/api/member/user'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export interface BrokerageBranch {
|
||||
children: BrokerageChildVO[]
|
||||
total: number
|
||||
pageNo: number
|
||||
expanded: boolean
|
||||
loading: boolean
|
||||
error: string
|
||||
warning: string
|
||||
}
|
||||
|
||||
export interface BrokerageTreeNode {
|
||||
member: MemberSummary & { status?: number | null; bindUserTime?: string | number | null }
|
||||
role: 'inviter' | 'current' | 'child'
|
||||
branch?: BrokerageBranch
|
||||
}
|
||||
|
||||
export function useBrokerageTree(user: Ref<UserVO | null | undefined>) {
|
||||
const root = ref<UserVO | null>(null)
|
||||
const branches = reactive<Record<number, BrokerageBranch>>({})
|
||||
const refreshing = ref(false)
|
||||
const refreshError = ref('')
|
||||
const parents = new Map<number, number>()
|
||||
let generation = 0
|
||||
let refreshVersion = 0
|
||||
|
||||
function createBranch(total = 0): BrokerageBranch {
|
||||
return {
|
||||
children: [],
|
||||
total,
|
||||
pageNo: 0,
|
||||
expanded: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
warning: ''
|
||||
}
|
||||
}
|
||||
|
||||
function hasMore(branch: BrokerageBranch) {
|
||||
return branch.pageNo === 0 || branch.pageNo * PAGE_SIZE < branch.total
|
||||
}
|
||||
|
||||
async function loadChildren(id: number) {
|
||||
const branch = branches[id]
|
||||
if (!branch || branch.loading || !hasMore(branch)) return
|
||||
const requestGeneration = generation
|
||||
branch.loading = true
|
||||
branch.error = ''
|
||||
try {
|
||||
const page = await UserApi.getBrokerageChildrenPage({
|
||||
bindUserId: id,
|
||||
pageNo: branch.pageNo + 1,
|
||||
pageSize: PAGE_SIZE
|
||||
})
|
||||
if (requestGeneration !== generation) return
|
||||
const ancestors = new Set<number>([id])
|
||||
let parent = parents.get(id)
|
||||
while (parent !== undefined && !ancestors.has(parent)) {
|
||||
ancestors.add(parent)
|
||||
parent = parents.get(parent)
|
||||
}
|
||||
const loaded = new Set(branch.children.map((child) => child.id))
|
||||
for (const child of page.list) {
|
||||
if (
|
||||
ancestors.has(child.id) ||
|
||||
child.bindUserId !== id ||
|
||||
(parents.has(child.id) && parents.get(child.id) !== id)
|
||||
) {
|
||||
branch.warning = '检测到异常邀请关系,已跳过异常节点,请刷新核对。'
|
||||
continue
|
||||
}
|
||||
if (loaded.has(child.id)) continue
|
||||
loaded.add(child.id)
|
||||
parents.set(child.id, id)
|
||||
branches[child.id] = createBranch(child.directChildCount)
|
||||
branch.children.push(child)
|
||||
}
|
||||
branch.total = page.total
|
||||
branch.pageNo += 1
|
||||
} catch {
|
||||
if (requestGeneration === generation) branch.error = '下级加载失败,请重试'
|
||||
} finally {
|
||||
if (requestGeneration === generation) branch.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize(value: UserVO | null | undefined) {
|
||||
generation += 1
|
||||
root.value = value?.id ? value : null
|
||||
parents.clear()
|
||||
Object.keys(branches).forEach((id) => delete branches[Number(id)])
|
||||
if (!root.value) return
|
||||
const id = root.value.id
|
||||
branches[id] = createBranch()
|
||||
branches[id].expanded = true
|
||||
if (root.value.inviter?.id === id) {
|
||||
branches[id].warning = '当前会员的邀请人指向自己,请核对邀请关系。'
|
||||
} else if (root.value.inviter) {
|
||||
parents.set(id, root.value.inviter.id)
|
||||
}
|
||||
await loadChildren(id)
|
||||
}
|
||||
|
||||
async function toggleBranch(id: number) {
|
||||
const branch = branches[id]
|
||||
if (!branch) return
|
||||
branch.expanded = !branch.expanded
|
||||
if (branch.expanded && branch.pageNo === 0) await loadChildren(id)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!root.value || refreshing.value) return
|
||||
const id = root.value.id
|
||||
const requestGeneration = generation
|
||||
const version = ++refreshVersion
|
||||
refreshing.value = true
|
||||
refreshError.value = ''
|
||||
try {
|
||||
const value = await UserApi.getUser(id)
|
||||
if (requestGeneration !== generation) return
|
||||
if (!value) {
|
||||
refreshError.value = '会员资料不存在'
|
||||
await initialize(null)
|
||||
return
|
||||
}
|
||||
await initialize(value)
|
||||
} catch {
|
||||
if (requestGeneration === generation) refreshError.value = '刷新失败,请重试'
|
||||
} finally {
|
||||
if (version === refreshVersion) refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const graph = computed(() => {
|
||||
const nodes: BrokerageTreeNode[] = []
|
||||
const edges: { source: number; target: number; bindUserTime?: string | number | null }[] = []
|
||||
const current = root.value
|
||||
if (!current) return { nodes, edges }
|
||||
if (current.inviter && current.inviter.id !== current.id) {
|
||||
nodes.push({ member: current.inviter, role: 'inviter' })
|
||||
edges.push({
|
||||
source: current.inviter.id,
|
||||
target: current.id,
|
||||
bindUserTime: current.inviter.bindUserTime
|
||||
})
|
||||
}
|
||||
nodes.push({
|
||||
member: { ...current, memberExists: true },
|
||||
role: 'current',
|
||||
branch: branches[current.id]
|
||||
})
|
||||
const pending = [current.id]
|
||||
const visited = new Set<number>([current.id])
|
||||
while (pending.length) {
|
||||
const id = pending.shift()!
|
||||
const branch = branches[id]
|
||||
if (!branch?.expanded) continue
|
||||
for (const child of branch.children) {
|
||||
if (visited.has(child.id)) continue
|
||||
visited.add(child.id)
|
||||
nodes.push({ member: child, role: 'child', branch: branches[child.id] })
|
||||
edges.push({ source: id, target: child.id, bindUserTime: child.bindUserTime })
|
||||
pending.push(child.id)
|
||||
}
|
||||
}
|
||||
return { nodes, edges }
|
||||
})
|
||||
|
||||
watch(
|
||||
user,
|
||||
(value) => {
|
||||
refreshVersion += 1
|
||||
refreshing.value = false
|
||||
refreshError.value = ''
|
||||
void initialize(value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
generation += 1
|
||||
refreshVersion += 1
|
||||
})
|
||||
|
||||
return {
|
||||
root,
|
||||
graph,
|
||||
branches,
|
||||
refreshing,
|
||||
refreshError,
|
||||
hasMore,
|
||||
loadChildren,
|
||||
toggleBranch,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
+114
-188
@@ -3,127 +3,56 @@
|
||||
|
||||
<ContentWrap>
|
||||
<!-- 搜索工作栏 -->
|
||||
<el-form
|
||||
ref="queryFormRef"
|
||||
:inline="true"
|
||||
:model="queryParams"
|
||||
class="-mb-15px"
|
||||
label-width="68px"
|
||||
>
|
||||
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px" label-width="68px">
|
||||
<el-form-item label="用户昵称" prop="nickname">
|
||||
<el-input
|
||||
v-model="queryParams.nickname"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
placeholder="请输入用户昵称"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
<el-input v-model="queryParams.nickname" class="!w-240px" clearable placeholder="请输入用户昵称"
|
||||
@keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID" prop="userCode">
|
||||
<el-input
|
||||
v-model="queryParams.userCode"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
placeholder="App 我的页面显示的 ID"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
<el-input v-model="queryParams.userCode" class="!w-240px" clearable placeholder="App 我的页面显示的 ID"
|
||||
@keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="mobile">
|
||||
<el-input
|
||||
v-model="queryParams.mobile"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
placeholder="请输入手机号"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
<el-input v-model="queryParams.mobile" class="!w-240px" clearable placeholder="请输入手机号"
|
||||
@keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="微信号" prop="wechatNum">
|
||||
<el-input
|
||||
v-model.trim="queryParams.wechatNum"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
placeholder="请输入微信号"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
<el-input v-model.trim="queryParams.wechatNum" class="!w-240px" clearable placeholder="请输入微信号"
|
||||
@keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="props.sex === undefined" label="用户性别" prop="sex">
|
||||
<el-select
|
||||
v-model="queryParams.sex"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
placeholder="请选择用户性别"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
<el-select v-model="queryParams.sex" class="!w-240px" clearable placeholder="请选择用户性别">
|
||||
<el-option v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)" :key="dict.value" :label="dict.label"
|
||||
:value="dict.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否禁用" prop="status">
|
||||
<el-select
|
||||
v-model="queryParams.status"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
placeholder="请选择禁用状态"
|
||||
>
|
||||
<el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择禁用状态">
|
||||
<el-option label="已禁用" :value="CommonStatusEnum.DISABLE" />
|
||||
<el-option label="未禁用" :value="CommonStatusEnum.ENABLE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户标签" prop="memberRecentTagList">
|
||||
<el-select
|
||||
v-model="queryParams.memberRecentTagList"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
filterable
|
||||
multiple
|
||||
placeholder="请选择用户标签"
|
||||
>
|
||||
<el-option
|
||||
v-for="dict in getStrDictOptions(DICT_TYPE.MEMBER_RECENT_TAG)"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
<el-select v-model="queryParams.memberRecentTagList" class="!w-240px" clearable collapse-tags
|
||||
collapse-tags-tooltip filterable multiple placeholder="请选择用户标签">
|
||||
<el-option v-for="dict in getStrDictOptions(DICT_TYPE.MEMBER_RECENT_TAG)" :key="dict.value"
|
||||
:label="dict.label" :value="dict.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户地址" prop="addressAreaId">
|
||||
<el-cascader
|
||||
v-model="queryParams.addressAreaId"
|
||||
:options="areaList"
|
||||
:props="{ ...defaultProps, checkStrictly: true }"
|
||||
class="!w-240px"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="请选择省/市/区"
|
||||
separator="-"
|
||||
/>
|
||||
<el-cascader v-model="queryParams.addressAreaId" :options="areaList"
|
||||
:props="{ ...defaultProps, checkStrictly: true }" class="!w-240px" clearable filterable placeholder="请选择省/市/区"
|
||||
separator="-" />
|
||||
</el-form-item>
|
||||
<el-form-item label="注册时间" prop="createTime">
|
||||
<el-date-picker
|
||||
v-model="queryParams.createTime"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
end-placeholder="结束日期"
|
||||
start-placeholder="开始日期"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<el-date-picker v-model="queryParams.createTime"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" end-placeholder="结束日期"
|
||||
start-placeholder="开始日期" type="daterange" value-format="YYYY-MM-DD HH:mm:ss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="登录时间" prop="loginDate">
|
||||
<el-date-picker
|
||||
v-model="queryParams.loginDate"
|
||||
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px"
|
||||
end-placeholder="结束日期"
|
||||
start-placeholder="开始日期"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
<el-date-picker v-model="queryParams.loginDate" :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
|
||||
class="!w-240px" end-placeholder="结束日期" start-placeholder="开始日期" type="daterange"
|
||||
value-format="YYYY-MM-DD HH:mm:ss" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="false" label="用户等级" prop="levelId">
|
||||
<MemberLevelSelect v-model="queryParams.levelId" />
|
||||
@@ -140,13 +69,8 @@
|
||||
<Icon class="mr-5px" icon="ep:refresh" />
|
||||
重置
|
||||
</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['member:user:query']"
|
||||
type="success"
|
||||
plain
|
||||
:loading="exportDialogRef?.submitting"
|
||||
@click="exportDialogRef?.open(queryParams)"
|
||||
>
|
||||
<el-button v-hasPermi="['member:user:query']" type="success" plain :loading="exportDialogRef?.submitting"
|
||||
@click="exportDialogRef?.open(queryParams)">
|
||||
<Icon class="mr-5px" icon="ep:download" />
|
||||
{{ exportDialogRef?.isActive ? '查看导出进度' : '导出' }}
|
||||
</el-button>
|
||||
@@ -157,19 +81,9 @@
|
||||
|
||||
<!-- 列表 -->
|
||||
<ContentWrap class="member-user-list">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
class="member-user-table"
|
||||
:data="list"
|
||||
:show-overflow-tooltip="false"
|
||||
:stripe="true"
|
||||
border
|
||||
flexible
|
||||
scrollbar-always-on
|
||||
:scrollbar-tabindex="0"
|
||||
table-layout="fixed"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table v-loading="loading" class="member-user-table" :data="list" :show-overflow-tooltip="false" :stripe="true"
|
||||
border flexible scrollbar-always-on :scrollbar-tabindex="0" table-layout="fixed"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column align="center" label="用户编号" prop="id" width="84px" />
|
||||
<el-table-column align="center" label="用户ID" prop="userCode" width="84px" />
|
||||
@@ -200,34 +114,18 @@
|
||||
{{ row.wechatNum || '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="微信二维码"
|
||||
prop="wechatQrCode"
|
||||
width="100px"
|
||||
:show-overflow-tooltip="false"
|
||||
>
|
||||
<el-table-column align="center" label="微信二维码" prop="wechatQrCode" width="100px" :show-overflow-tooltip="false">
|
||||
<template #default="{ row }">
|
||||
<PreviewImage
|
||||
v-if="row.wechatQrCode"
|
||||
:src="row.wechatQrCode"
|
||||
:width="64"
|
||||
:height="64"
|
||||
fit="contain"
|
||||
alt="微信二维码"
|
||||
/>
|
||||
<PreviewImage v-if="row.wechatQrCode" :src="row.wechatQrCode" :width="64" :height="64" fit="contain"
|
||||
alt="微信二维码" />
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" min-width="184px" label="用户标签">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.memberTagList?.length" class="member-user-tags">
|
||||
<dict-tag
|
||||
v-for="v in row.memberTagList"
|
||||
:key="v.tagBizType"
|
||||
:type="DICT_TYPE.MEMBER_RECENT_TAG"
|
||||
:value="v.tagBizType"
|
||||
/>
|
||||
<dict-tag v-for="v in row.memberTagList" :key="v.tagBizType" :type="DICT_TYPE.MEMBER_RECENT_TAG"
|
||||
:value="v.tagBizType" />
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
@@ -235,17 +133,30 @@
|
||||
<el-table-column align="center" label="是否禁用" width="92px">
|
||||
<template #default="{ row }">
|
||||
<div class="switch-cell">
|
||||
<el-switch
|
||||
v-model="row.status"
|
||||
size="small"
|
||||
:active-value="CommonStatusEnum.DISABLE"
|
||||
:inactive-value="CommonStatusEnum.ENABLE"
|
||||
:disabled="!checkPermi(['member:user:update'])"
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
<el-switch v-model="row.status" size="small" :active-value="CommonStatusEnum.DISABLE"
|
||||
:inactive-value="CommonStatusEnum.ENABLE" :disabled="!checkPermi(['member:user:update'])"
|
||||
@change="handleStatusChange(row)" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="邀请人" min-width="196px">
|
||||
<template #default="{ row }">
|
||||
<component :is="row.inviter?.memberExists ? 'button' : 'div'" v-if="row.inviter" class="member-user-inviter"
|
||||
:class="{ 'member-user-inviter--link': row.inviter.memberExists }"
|
||||
:type="row.inviter.memberExists ? 'button' : undefined"
|
||||
:aria-label="row.inviter.memberExists ? `查看邀请人${row.inviter.nickname || row.inviter.id}的详情` : undefined"
|
||||
@click="row.inviter.memberExists && openDetail(row.inviter.id)">
|
||||
<el-avatar :src="row.inviter.avatar || undefined" :size="32" aria-hidden="true">
|
||||
<Icon icon="ep:user" />
|
||||
</el-avatar>
|
||||
<span class="member-user-inviter__text">
|
||||
<span>{{ row.inviter.memberExists ? row.inviter.nickname || '未设置昵称' : '会员资料缺失' }}</span>
|
||||
<span class="member-user-inviter__id">ID {{ row.inviter.userCode || row.inviter.id }}</span>
|
||||
</span>
|
||||
</component>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="时间" min-width="256px">
|
||||
<template #default="{ row }">
|
||||
<div class="member-user-times">
|
||||
@@ -260,58 +171,34 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
:show-overflow-tooltip="false"
|
||||
align="center"
|
||||
fixed="right"
|
||||
label="操作"
|
||||
width="144px"
|
||||
>
|
||||
<el-table-column :show-overflow-tooltip="false" align="center" fixed="right" label="操作" width="144px">
|
||||
<template #default="{ row }">
|
||||
<div class="member-user-actions">
|
||||
<el-button link type="primary" @click="openDetail(row.id)">详情</el-button>
|
||||
<el-dropdown
|
||||
v-hasPermi="[
|
||||
'member:user:update',
|
||||
'member:user:update-level',
|
||||
'member:user:update-point'
|
||||
]"
|
||||
@command="(command) => handleCommand(command, row)"
|
||||
>
|
||||
<el-dropdown v-hasPermi="[
|
||||
'member:user:update',
|
||||
'member:user:update-level',
|
||||
'member:user:update-point'
|
||||
]" @command="(command) => handleCommand(command, row)">
|
||||
<el-button link type="primary">
|
||||
<Icon icon="ep:d-arrow-right" />
|
||||
更多
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-if="checkPermi(['member:user:update'])"
|
||||
command="handleUpdate"
|
||||
>
|
||||
<el-dropdown-item v-if="checkPermi(['member:user:update'])" command="handleUpdate">
|
||||
编辑
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="checkPermi(['member:user:update-level'])"
|
||||
command="handleUpdateLevel"
|
||||
>
|
||||
<el-dropdown-item v-if="checkPermi(['member:user:update-level'])" command="handleUpdateLevel">
|
||||
修改等级
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="checkPermi(['member:user:update-point'])"
|
||||
command="handleUpdatePoint"
|
||||
>
|
||||
<el-dropdown-item v-if="checkPermi(['member:user:update-point'])" command="handleUpdatePoint">
|
||||
修改积分
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="checkPermi(['pay:wallet:update-member-tag'])"
|
||||
command="handleUpdateMemberTag"
|
||||
>
|
||||
<el-dropdown-item v-if="checkPermi(['pay:wallet:update-member-tag'])" command="handleUpdateMemberTag">
|
||||
修改用户标签
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="checkPermi(['pay:wallet:update-member-tag'])"
|
||||
command="handleOpenVipTag"
|
||||
>
|
||||
<el-dropdown-item v-if="checkPermi(['pay:wallet:update-member-tag'])" command="handleOpenVipTag">
|
||||
开通会员
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
@@ -322,12 +209,8 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<Pagination
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNo"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
<Pagination v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNo" :total="total"
|
||||
@pagination="getList" />
|
||||
</ContentWrap>
|
||||
|
||||
<!-- 表单弹窗:添加/修改 -->
|
||||
@@ -559,7 +442,7 @@ onMounted(async () => {
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
|
||||
> div {
|
||||
>div {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
@@ -578,6 +461,49 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-user-inviter {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--el-text-color-primary);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
|
||||
.el-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__id {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&--link {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const vue = require('vue')
|
||||
const { parse, compileTemplate } = require('vue/compiler-sfc')
|
||||
|
||||
const detailDirectory = path.join(__dirname, '../src/views/member/user/detail')
|
||||
const hookSource = fs.readFileSync(path.join(detailDirectory, 'useBrokerageTree.ts'), 'utf8')
|
||||
const transpile = (source) =>
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const child = (id, bindUserId, directChildCount = 0) => ({
|
||||
id,
|
||||
bindUserId,
|
||||
directChildCount,
|
||||
userCode: `U${id}`,
|
||||
nickname: `会员${id}`,
|
||||
memberExists: true
|
||||
})
|
||||
const member = (id = 1, inviterId = 90) => ({
|
||||
id,
|
||||
userCode: `U${id}`,
|
||||
nickname: `会员${id}`,
|
||||
inviter: inviterId
|
||||
? { id: inviterId, nickname: '邀请人', userCode: `U${inviterId}`, memberExists: true }
|
||||
: null
|
||||
})
|
||||
const deferred = () => {
|
||||
let resolve, reject
|
||||
const promise = new Promise((done, fail) => {
|
||||
resolve = done
|
||||
reject = fail
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const flush = async () => {
|
||||
await vue.nextTick()
|
||||
for (let index = 0; index < 8; index++) await Promise.resolve()
|
||||
}
|
||||
|
||||
function harness(overrides = {}, initial = member()) {
|
||||
const calls = []
|
||||
const api = {
|
||||
getBrokerageChildrenPage: async () => ({ list: [], total: 0 }),
|
||||
getUser: async (id) => member(id),
|
||||
...overrides
|
||||
}
|
||||
const childQuery = api.getBrokerageChildrenPage
|
||||
api.getBrokerageChildrenPage = (query) => {
|
||||
calls.push({ ...query })
|
||||
return childQuery(query)
|
||||
}
|
||||
let unmount
|
||||
const runtimeVue = {
|
||||
...vue,
|
||||
onBeforeUnmount: (fn) => {
|
||||
unmount = fn
|
||||
}
|
||||
}
|
||||
const module = { exports: {} }
|
||||
vm.runInNewContext(transpile(hookSource), {
|
||||
exports: module.exports,
|
||||
require: (name) => {
|
||||
if (name === 'vue') return runtimeVue
|
||||
if (name === '@/api/member/user') return api
|
||||
throw new Error(`Unexpected import ${name}`)
|
||||
}
|
||||
})
|
||||
const user = vue.ref(initial)
|
||||
const scope = vue.effectScope()
|
||||
const tree = scope.run(() => module.exports.useBrokerageTree(user))
|
||||
return {
|
||||
tree,
|
||||
user,
|
||||
api,
|
||||
calls,
|
||||
stop: () => {
|
||||
unmount()
|
||||
scope.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
const nodeIds = (tree) => Array.from(tree.graph.value.nodes, ({ member }) => member.id)
|
||||
|
||||
test('会员列表与详情模板可编译,邀请人紧跟禁用列,关系树使用懒加载页签', () => {
|
||||
for (const file of [
|
||||
path.join(detailDirectory, '../index.vue'),
|
||||
path.join(detailDirectory, 'index.vue'),
|
||||
path.join(detailDirectory, 'UserBrokerageTree.vue')
|
||||
]) {
|
||||
const { descriptor } = parse(fs.readFileSync(file, 'utf8'))
|
||||
assert.deepEqual(
|
||||
compileTemplate({ source: descriptor.template.content, filename: file, id: 'brokerage' })
|
||||
.errors,
|
||||
[]
|
||||
)
|
||||
}
|
||||
const list = fs.readFileSync(path.join(detailDirectory, '../index.vue'), 'utf8')
|
||||
assert.match(
|
||||
list,
|
||||
/label="是否禁用"[\s\S]*?<\/el-table-column>\s*<el-table-column label="邀请人"/
|
||||
)
|
||||
assert.match(list, /openDetail\(row.inviter.id\)/)
|
||||
const detail = fs.readFileSync(path.join(detailDirectory, 'index.vue'), 'utf8')
|
||||
assert.match(detail, /label="分销关系" name="brokerage" lazy/)
|
||||
assert.match(detail, /defineAsyncComponent\(\(\) => import\('\.\/UserBrokerageTree.vue'\)\)/)
|
||||
assert.match(detail, /label="推广用户" lazy/)
|
||||
})
|
||||
|
||||
test('等待会员资料时不查询,未绑定邀请人仍展示当前会员', async () => {
|
||||
const h = harness({}, null)
|
||||
assert.equal(h.calls.length, 0)
|
||||
h.user.value = member(1, null)
|
||||
await flush()
|
||||
assert.deepEqual(nodeIds(h.tree), [1])
|
||||
assert.equal(h.tree.branches[1].total, 0)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('直属下级按20人分页,去重后继续正确推进页码', async () => {
|
||||
const h = harness({
|
||||
getBrokerageChildrenPage: async ({ pageNo }) =>
|
||||
pageNo === 1
|
||||
? { list: Array.from({ length: 20 }, (_, index) => child(30 - index, 1)), total: 24 }
|
||||
: { list: [child(11, 1), child(10, 1), child(9, 1), child(8, 1)], total: 24 }
|
||||
})
|
||||
await flush()
|
||||
assert.deepEqual(nodeIds(h.tree).slice(0, 3), [90, 1, 30])
|
||||
await h.tree.loadChildren(1)
|
||||
assert.equal(h.tree.branches[1].children.length, 23)
|
||||
assert.equal(h.tree.hasMore(h.tree.branches[1]), false)
|
||||
await h.tree.loadChildren(1)
|
||||
assert.deepEqual(h.calls, [
|
||||
{ bindUserId: 1, pageNo: 1, pageSize: 20 },
|
||||
{ bindUserId: 1, pageNo: 2, pageSize: 20 }
|
||||
])
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('可展开超过三级,收起后再次展开复用已加载的分支与展开状态', async () => {
|
||||
const h = harness({
|
||||
getBrokerageChildrenPage: async ({ bindUserId }) => ({
|
||||
list: bindUserId < 4 ? [child(bindUserId + 1, bindUserId, 1)] : [],
|
||||
total: bindUserId < 4 ? 1 : 0
|
||||
})
|
||||
})
|
||||
await flush()
|
||||
await h.tree.toggleBranch(2)
|
||||
await h.tree.toggleBranch(3)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3, 4])
|
||||
await h.tree.toggleBranch(2)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2])
|
||||
await h.tree.toggleBranch(2)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3, 4])
|
||||
assert.equal(h.calls.length, 3)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('分支失败后可重试原页,已有节点继续显示', async () => {
|
||||
let attempts = 0
|
||||
const h = harness({
|
||||
getBrokerageChildrenPage: async ({ pageNo }) => {
|
||||
if (pageNo === 1) return { list: [child(2, 1)], total: 21 }
|
||||
if (++attempts === 1) throw new Error('offline')
|
||||
return { list: [child(3, 1)], total: 21 }
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
await h.tree.loadChildren(1)
|
||||
assert.equal(h.tree.branches[1].pageNo, 1)
|
||||
assert.match(h.tree.branches[1].error, /重试/)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2])
|
||||
await h.tree.loadChildren(1)
|
||||
assert.equal(h.tree.branches[1].error, '')
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3])
|
||||
assert.equal(h.calls.at(-1).pageNo, 2)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('初次加载失败可重试,并发重复点击只发出一次请求', async () => {
|
||||
const pending = deferred()
|
||||
const h = harness({ getBrokerageChildrenPage: () => pending.promise })
|
||||
await h.tree.loadChildren(1)
|
||||
assert.equal(h.calls.length, 1)
|
||||
pending.reject(new Error('offline'))
|
||||
await flush()
|
||||
assert.equal(h.tree.branches[1].loading, false)
|
||||
h.api.getBrokerageChildrenPage = async () => ({ list: [child(2, 1)], total: 1 })
|
||||
await h.tree.loadChildren(1)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2])
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('切换会员后忽略旧分支的迟到响应', async () => {
|
||||
const old = deferred()
|
||||
const h = harness({
|
||||
getBrokerageChildrenPage: ({ bindUserId }) =>
|
||||
bindUserId === 1 ? old.promise : Promise.resolve({ list: [child(20, 10)], total: 1 })
|
||||
})
|
||||
h.user.value = member(10, 99)
|
||||
await flush()
|
||||
old.resolve({ list: [child(2, 1)], total: 1 })
|
||||
await flush()
|
||||
assert.deepEqual(nodeIds(h.tree), [99, 10, 20])
|
||||
assert.equal(h.tree.branches[1], undefined)
|
||||
assert.equal(h.tree.branches[2], undefined)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('刷新重新读取邀请人并清空旧分支,连续点击只刷新一次', async () => {
|
||||
const pending = deferred()
|
||||
let reads = 0
|
||||
const h = harness({
|
||||
getUser: () => {
|
||||
reads++
|
||||
return pending.promise
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
const task = h.tree.refresh()
|
||||
await h.tree.refresh()
|
||||
assert.equal(reads, 1)
|
||||
pending.resolve(member(1, 99))
|
||||
await task
|
||||
assert.deepEqual(nodeIds(h.tree), [99, 1])
|
||||
assert.equal(h.tree.refreshing.value, false)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('刷新失败保留已加载关系并允许再次刷新,切换会员会清除错误', async () => {
|
||||
const h = harness({
|
||||
getUser: async () => {
|
||||
throw new Error('offline')
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
await h.tree.refresh()
|
||||
assert.match(h.tree.refreshError.value, /重试/)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1])
|
||||
h.api.getUser = async () => member(1, 99)
|
||||
await h.tree.refresh()
|
||||
assert.equal(h.tree.refreshError.value, '')
|
||||
assert.deepEqual(nodeIds(h.tree), [99, 1])
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('历史循环、错误父节点和重复数据不会形成环或重复节点,缺失会员资料仍可查看关系', async () => {
|
||||
const h = harness({
|
||||
getBrokerageChildrenPage: async ({ bindUserId }) =>
|
||||
bindUserId === 1
|
||||
? {
|
||||
list: [
|
||||
child(1, 1),
|
||||
child(90, 1),
|
||||
child(8, 999),
|
||||
{ ...child(2, 1, 1), memberExists: false },
|
||||
child(2, 1)
|
||||
],
|
||||
total: 5
|
||||
}
|
||||
: { list: [child(1, 2), child(3, 2)], total: 2 }
|
||||
})
|
||||
await flush()
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2])
|
||||
assert.match(h.tree.branches[1].warning, /异常/)
|
||||
assert.equal(h.tree.graph.value.nodes.at(-1).member.memberExists, false)
|
||||
await h.tree.toggleBranch(2)
|
||||
assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3])
|
||||
assert.match(h.tree.branches[2].warning, /异常/)
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('会员邀请自己时显示异常提示,组件卸载后忽略请求结果', async () => {
|
||||
const pending = deferred()
|
||||
const h = harness({ getBrokerageChildrenPage: () => pending.promise }, member(1, 1))
|
||||
assert.deepEqual(nodeIds(h.tree), [1])
|
||||
assert.match(h.tree.branches[1].warning, /自己/)
|
||||
h.stop()
|
||||
pending.resolve({ list: [child(2, 1)], total: 1 })
|
||||
await flush()
|
||||
assert.equal(h.tree.branches[2], undefined)
|
||||
})
|
||||
|
||||
test('实际Dagre布局在宽分支和三级关系中无节点重叠,节点详情跳转使用内部编号', async () => {
|
||||
const h = harness({
|
||||
getBrokerageChildrenPage: async () => ({ list: [child(2, 1, 1), child(3, 1)], total: 2 })
|
||||
})
|
||||
await flush()
|
||||
const { descriptor } = parse(
|
||||
fs.readFileSync(path.join(detailDirectory, 'UserBrokerageTree.vue'), 'utf8')
|
||||
)
|
||||
const props = vue.reactive({ user: member(), active: true })
|
||||
const routes = []
|
||||
const context = {
|
||||
exports: {},
|
||||
defineProps: () => props,
|
||||
useRouter: () => ({ push: (route) => routes.push(route) }),
|
||||
require: (name) => {
|
||||
if (name === 'vue') return { ...vue, useId: () => 'test' }
|
||||
if (name === '@vue-flow/core')
|
||||
return {
|
||||
Position: { Top: 'top', Bottom: 'bottom' },
|
||||
MarkerType: { ArrowClosed: 'arrowclosed' },
|
||||
ConnectionMode: { Strict: 'strict' },
|
||||
useVueFlow: () => ({
|
||||
onNodesInitialized: () => {},
|
||||
fitView: async () => {},
|
||||
zoomIn: () => {},
|
||||
zoomOut: () => {}
|
||||
})
|
||||
}
|
||||
if (name === '@vue-flow/controls' || name.endsWith('.css')) return {}
|
||||
if (name === '@dagrejs/dagre') return require('@dagrejs/dagre')
|
||||
if (name === '@/utils/constants') return { CommonStatusEnum: { DISABLE: 1 } }
|
||||
if (name === '@/utils/formatTime') return { formatDate: (value) => value.toISOString() }
|
||||
if (name === './useBrokerageTree') return { useBrokerageTree: () => h.tree }
|
||||
throw new Error(`Unexpected import ${name}`)
|
||||
}
|
||||
}
|
||||
const scope = vue.effectScope()
|
||||
scope.run(() =>
|
||||
vm.runInNewContext(
|
||||
transpile(descriptor.scriptSetup.content) + '\nglobalThis.state = { layout, openDetail };',
|
||||
context
|
||||
)
|
||||
)
|
||||
const nodes = context.state.layout.value.nodes
|
||||
assert.equal(nodes.length, 4)
|
||||
assert.ok(nodes.every((node) => node.style.pointerEvents === 'all'))
|
||||
assert.ok(
|
||||
context.state.layout.value.edges.every(
|
||||
(edge) => edge.sourceHandle === 'out' && edge.targetHandle === 'in'
|
||||
)
|
||||
)
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
for (let other = index + 1; other < nodes.length; other++) {
|
||||
assert.ok(
|
||||
Math.abs(nodes[index].position.x - nodes[other].position.x) >= 248 ||
|
||||
Math.abs(nodes[index].position.y - nodes[other].position.y) >= 174
|
||||
)
|
||||
}
|
||||
}
|
||||
context.state.openDetail(2)
|
||||
assert.equal(routes[0].params.id, 2)
|
||||
assert.equal(routes[0].name, 'MemberUserDetail')
|
||||
scope.stop()
|
||||
h.stop()
|
||||
})
|
||||
Reference in new issue
Block a user