feat:昵称重复、显示分销人分销tree

This commit is contained in:
dzc committed 2026-11-09 13:24:36 +08:00
1 parent c1411e0fde
commit ee06dbaf9b
9 files changed
+1243 -268

No files matched your search

+3
View File
@@ -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",
+48
View File
@@ -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)':
+32
View File
@@ -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 })
+25 -79
View File
@@ -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>
+8 -1
View File
@@ -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
View File
@@ -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;
+352
View File
@@ -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()
})