diff --git a/src/api/app/push/index.ts b/src/api/app/push/index.ts new file mode 100644 index 00000000..87961bb7 --- /dev/null +++ b/src/api/app/push/index.ts @@ -0,0 +1,91 @@ +import request from '@/config/axios' + +export type PushSource = 'AUTO' | 'MANUAL' +export type PushTargetType = 'USER' | 'ALL' +export type PushTaskStatus = + 'WAITING' | 'RUNNING' | 'COMPLETED' | 'PARTIAL_FAILED' | 'FAILED' | 'SKIPPED' +export type PushRecordStatus = 'WAITING' | 'SENDING' | 'SUBMITTED' | 'FAILED' | 'SKIPPED' + +export interface PushTask { + id: number + source: PushSource + targetType: PushTargetType + title: string + content: string + messageId?: number + senderId?: number + receiverId?: number + status: PushTaskStatus + totalMembers: number + totalDevices: number + submittedCount: number + failedCount: number + skippedCount: number + processedCount: number + createTime: string | number + finishTime?: string | number + lastError?: string +} + +export interface PushRecord { + id: number + taskId: number + memberId: number + nickname?: string + userCode?: string + platform?: string + registrationId?: string + status: PushRecordStatus + attemptCount: number + cid?: string + jpushMsgId?: string + errorCode?: string + errorMessage?: string + createTime: string | number + sendTime?: string | number +} + +export interface PushMember { + id: number + nickname?: string + userCode?: string + mobile?: string + deviceCount: number +} + +export interface PushSendRequest { + requestId: string + targetType: PushTargetType + memberIds: number[] + title: string + content: string +} + +export interface PushTaskPageRequest { + pageNo: number + pageSize: number + source?: PushSource + memberId?: number + status?: PushTaskStatus + createTime?: string[] +} + +export const sendPush = (data: PushSendRequest) => + request.post<{ taskId: number }>({ url: '/member/push/send', data }) + +export const getPushTaskPage = (params: PushTaskPageRequest) => + request.get<{ list: PushTask[]; total: number }>({ url: '/member/push/page', params }) + +export const getPushTask = (id: number) => + request.get({ url: '/member/push/get', params: { id } }) + +export const getPushRecordPage = (params: { taskId: number; pageNo: number; pageSize: number }) => + request.get<{ list: PushRecord[]; total: number }>({ url: '/member/push/record-page', params }) + +export const getPushMemberPage = (params: { + pageNo: number + pageSize: number + nickname?: string + userCode?: string + mobile?: string +}) => request.get<{ list: PushMember[]; total: number }>({ url: '/member/push/user-page', params }) diff --git a/src/api/app/version/index.ts b/src/api/app/version/index.ts new file mode 100644 index 00000000..486f0927 --- /dev/null +++ b/src/api/app/version/index.ts @@ -0,0 +1,148 @@ +import request from '@/config/axios' +import type { AxiosProgressEvent } from 'axios' + +/** 安装包适用的运行平台。 */ +export type AppPlatform = 'android' | 'ios' | 'harmony' +/** 发布生命周期,下架版本可编辑后重新发布。 */ +export type VersionStatus = 'draft' | 'published' | 'offline' +/** 全量发布或指定会员灰测。 */ +export type ReleaseScope = 'all' | 'members' + +/** 后台从安装包解析的可信元数据,版本与校验值均由服务端生成。 */ +export interface VersionPackage { + id: number + appId: string + /** 兼容旧数据的目标平台,可能为逗号分隔的多平台值。 */ + platform: string + /** 发布目标平台集合,管理端优先使用此字段。 */ + platforms: AppPlatform[] + packageType: 'apk' | 'wgt' + fileName: string + fileSize: number + sha256: string + md5: string + downloadUrl: string + versionCode: number + versionName: string + nativeVersionCode?: number + resourceVersionCode: number + packageName?: string +} + +/** 灰测名单中的会员简要资料,id 是内部会员编号。 */ +export interface GrayMember { + id: number + userCode?: string | null + nickname?: string | null + avatar?: string | null + mobile?: string | null + status?: number +} + +/** 版本列表和详情使用同一份发布字段,详情额外返回灰测会员。 */ +export interface AppVersion extends Omit { + id: number + packageId: number + updateContent: string + forceUpdate: boolean + releaseScope: ReleaseScope + status: VersionStatus + minNativeVersionCode?: number + maxNativeVersionCode?: number + memberCount: number + memberIds?: number[] + members?: GrayMember[] + createTime?: string | number + publishTime?: string | number +} + +/** 保存草稿和下架版本时允许修改的内容,包内版本信息保持只读。 */ +export interface VersionSaveRequest { + id?: number + packageId: number + platforms: AppPlatform[] + updateContent: string + forceUpdate: boolean + releaseScope: ReleaseScope + minNativeVersionCode?: number + maxNativeVersionCode?: number + memberIds: number[] +} + +/** 发布列表使用服务端分页与组合筛选。 */ +export interface VersionPageRequest { + pageNo: number + pageSize: number + keyword?: string + platform?: AppPlatform + packageType?: 'apk' | 'wgt' + status?: VersionStatus + releaseScope?: ReleaseScope +} + +/** 统一读取发布目标,兼容改造前的单平台和逗号分隔平台字段。 */ +export const getTargetPlatforms = (version: { + platforms?: AppPlatform[] + platform?: string +}): AppPlatform[] => { + const declared = version.platforms?.length + ? version.platforms + : version.platform?.split(',') || [] + const supported: AppPlatform[] = ['android', 'ios', 'harmony'] + return supported.filter((platform) => declared.includes(platform)) +} + +/** 查询版本列表,默认由后台按编号倒序排列。 */ +export const getVersionPage = (params: VersionPageRequest) => + request.get<{ list: AppVersion[]; total: number }>({ url: '/member/app-version/page', params }) + +/** 读取发布详情及该版本的灰测会员名单。 */ +export const getVersion = (id: number) => + request.get({ url: '/member/app-version/get', params: { id } }) + +/** 新建草稿,发布动作由单独接口确认。 */ +export const createVersion = (data: VersionSaveRequest) => + request.post({ url: '/member/app-version/create', data }) + +/** 保存草稿或下架版本。 */ +export const updateVersion = (data: VersionSaveRequest) => + request.put({ url: '/member/app-version/update', data }) + +/** 删除允许删除的发布记录。 */ +export const deleteVersion = (id: number) => + request.delete({ url: '/member/app-version/delete', params: { id } }) + +/** 将草稿或下架版本发布到保存的目标人群。 */ +export const publishVersion = (id: number) => + request.put({ url: '/member/app-version/publish', params: { id } }) + +/** 下架已发布版本,停止向客户端提供该版本。 */ +export const offlineVersion = (id: number) => + request.put({ url: '/member/app-version/offline', params: { id } }) + +/** 将当前灰测版本提升为全量发布。 */ +export const promoteVersion = (id: number) => + request.put({ url: '/member/app-version/promote', params: { id } }) + +/** 在版本管理权限内检索可参与灰测的有效会员。 */ +export const getMemberPage = (params: { pageNo: number; pageSize: number; keyword?: string }) => + request.get<{ list: GrayMember[]; total: number }>({ + url: '/member/app-version/member-page', + params + }) + +/** 上传安装包并等待服务端校验,返回完整响应包装中的包元数据。 */ +export const uploadPackage = async ( + file: File, + onUploadProgress: (event: AxiosProgressEvent) => void +) => { + const data = new FormData() + data.append('file', file) + const result = await request.upload<{ data: VersionPackage }>({ + url: '/member/app-version/upload-package', + data, + timeout: 600000, + onUploadProgress + }) + return result.data +} diff --git a/src/api/mall/trade/brokerage/record/index.ts b/src/api/mall/trade/brokerage/record/index.ts index f908bb9a..1d6085be 100644 --- a/src/api/mall/trade/brokerage/record/index.ts +++ b/src/api/mall/trade/brokerage/record/index.ts @@ -23,6 +23,7 @@ export interface BrokerageRecordPageParams { pageNo: number pageSize: number userId?: number | string | null + userNickname?: string sourceUserId?: number | string | null sourceUserLevel?: number bizType?: number diff --git a/src/views/app/push/PushSendForm.vue b/src/views/app/push/PushSendForm.vue new file mode 100644 index 00000000..c43b805c --- /dev/null +++ b/src/views/app/push/PushSendForm.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/src/views/app/push/PushTaskDetail.vue b/src/views/app/push/PushTaskDetail.vue new file mode 100644 index 00000000..f941370f --- /dev/null +++ b/src/views/app/push/PushTaskDetail.vue @@ -0,0 +1,232 @@ + + + diff --git a/src/views/app/push/index.vue b/src/views/app/push/index.vue new file mode 100644 index 00000000..37582257 --- /dev/null +++ b/src/views/app/push/index.vue @@ -0,0 +1,217 @@ + + + diff --git a/src/views/app/push/push.data.ts b/src/views/app/push/push.data.ts new file mode 100644 index 00000000..4bba4f8c --- /dev/null +++ b/src/views/app/push/push.data.ts @@ -0,0 +1,29 @@ +import type { TagProps } from 'element-plus' +import type { PushRecordStatus, PushTaskStatus } from '@/api/app/push' + +interface PushStatusOption { + label: string + type: TagProps['type'] +} + +export const taskStatuses: Record = { + WAITING: { label: '待处理', type: 'info' }, + RUNNING: { label: '处理中', type: 'primary' }, + COMPLETED: { label: '提交完成', type: 'success' }, + PARTIAL_FAILED: { label: '部分失败', type: 'warning' }, + FAILED: { label: '失败', type: 'danger' }, + SKIPPED: { label: '已跳过', type: 'info' } +} + +export const recordStatuses: Record = { + WAITING: { label: '待提交', type: 'info' }, + SENDING: { label: '提交中', type: 'primary' }, + SUBMITTED: { label: '已提交极光', type: 'success' }, + FAILED: { label: '提交失败', type: 'danger' }, + SKIPPED: { label: '已跳过', type: 'info' } +} + +export const isTaskActive = (status: PushTaskStatus) => status === 'WAITING' || status === 'RUNNING' + +export const getTaskStatus = (status: PushTaskStatus) => taskStatuses[status] +export const getRecordStatus = (status: PushRecordStatus) => recordStatuses[status] diff --git a/src/views/app/version/MemberSelector.vue b/src/views/app/version/MemberSelector.vue new file mode 100644 index 00000000..56a77ffc --- /dev/null +++ b/src/views/app/version/MemberSelector.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/views/app/version/VersionForm.vue b/src/views/app/version/VersionForm.vue new file mode 100644 index 00000000..eedca107 --- /dev/null +++ b/src/views/app/version/VersionForm.vue @@ -0,0 +1,824 @@ + + + + + diff --git a/src/views/app/version/index.vue b/src/views/app/version/index.vue new file mode 100644 index 00000000..47ca264a --- /dev/null +++ b/src/views/app/version/index.vue @@ -0,0 +1,642 @@ + + + + + diff --git a/src/views/mall/trade/brokerage/record/BrokerageRecordList.vue b/src/views/mall/trade/brokerage/record/BrokerageRecordList.vue index ce67a665..34f3f146 100644 --- a/src/views/mall/trade/brokerage/record/BrokerageRecordList.vue +++ b/src/views/mall/trade/brokerage/record/BrokerageRecordList.vue @@ -1,61 +1,33 @@ @@ -164,7 +105,7 @@ const list = ref([]) const queryParams = reactive({ pageNo: 1, pageSize: 10, - userId: '', + userNickname: '', sourceUserId: '', status: props.defaultStatus as number | undefined, createTime: [] as string[] | undefined @@ -180,7 +121,9 @@ async function getList() { const data = await getBrokerageRecordPage({ pageNo: queryParams.pageNo, pageSize: queryParams.pageSize, - userId: props.fixedUserId ?? (queryParams.userId.trim() || undefined), + userId: props.fixedUserId, + userNickname: + props.fixedUserId === undefined ? queryParams.userNickname.trim() || undefined : undefined, sourceUserId: props.fixedSourceUserId ?? (queryParams.sourceUserId.trim() || undefined), bizType: props.fixedBizType, bizId: props.fixedBizId, @@ -207,7 +150,7 @@ function handleQuery() { } function resetQuery() { - queryParams.userId = '' + queryParams.userNickname = '' queryParams.sourceUserId = '' queryParams.status = props.defaultStatus queryParams.createTime = [] @@ -239,24 +182,29 @@ onBeforeUnmount(() => { align-items: center; gap: 8px; min-width: 0; + .el-avatar { flex-shrink: 0; } + div { display: grid; gap: 3px; min-width: 0; } + span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + small { color: var(--el-text-color-secondary); font-size: 12px; } } + &__error { display: flex; align-items: center; diff --git a/src/views/mall/trade/brokerage/withdraw/index.vue b/src/views/mall/trade/brokerage/withdraw/index.vue index c81ccab8..38edad24 100644 --- a/src/views/mall/trade/brokerage/withdraw/index.vue +++ b/src/views/mall/trade/brokerage/withdraw/index.vue @@ -10,10 +10,10 @@ :inline="true" label-width="68px" > - +
当前会员 + 刷新 @@ -134,7 +139,7 @@ type="primary" size="small" :loading="data.branch.loading" - @click="loadChildren(data.member.id)" + @click="retryChildren(data.member.id)" >重试

滚轮缩放,拖动画布移动。每个分支每次加载 20 人,悬停时钟查看绑定时间。

默认加载并展开两级下级,更深层级可继续展开。滚轮缩放,拖动画布移动,悬停时钟查看绑定时间。

@@ -250,8 +258,18 @@ 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 { + root, + graph, + refreshing, + refreshError, + hideNoIncome, + hasMore, + loadChildren, + retryChildren, + toggleBranch, + refresh +} = useBrokerageTree(toRef(props, 'user')) const flowId = `member-brokerage-${useId()}` const { fitView, zoomIn, zoomOut, onNodesInitialized } = useVueFlow({ id: flowId }) const { push } = useRouter() @@ -337,10 +355,9 @@ async function fitTree() { onNodesInitialized(fitTree) watch( - () => props.active, - (active) => { - if (active) void fitTree() - } + [() => props.active, () => graph.value.nodes.map(({ member }) => member.id).join(',')], + () => void fitTree(), + { flush: 'post' } ) @@ -379,6 +396,7 @@ watch( } &__tools { gap: 16px; + flex-wrap: wrap; } &__legend { gap: 6px; diff --git a/src/views/member/user/detail/useBrokerageTree.ts b/src/views/member/user/detail/useBrokerageTree.ts index a40c62f6..1176453e 100644 --- a/src/views/member/user/detail/useBrokerageTree.ts +++ b/src/views/member/user/detail/useBrokerageTree.ts @@ -25,6 +25,7 @@ export function useBrokerageTree(user: Ref) { const branches = reactive>({}) const refreshing = ref(false) const refreshError = ref('') + const hideNoIncome = ref(false) const parents = new Map() let generation = 0 let refreshVersion = 0 @@ -90,6 +91,42 @@ export function useBrokerageTree(user: Ref) { } } + async function loadBranchPages(id: number) { + const requestGeneration = generation + const branch = branches[id] + while (requestGeneration === generation && branch && !branch.loading && hasMore(branch)) { + const pageNo = branch.pageNo + await loadChildren(id) + if (branch.pageNo === pageNo) break + } + } + + async function loadFirstTwoLevels() { + if (!root.value) return + const id = root.value.id + const requestGeneration = generation + await loadBranchPages(id) + if (requestGeneration !== generation) return + const children = branches[id].children.filter((child) => child.directChildCount > 0) + // Limit simultaneous branch requests while loading the complete second level. + for (let index = 0; index < children.length; index += 4) { + if (requestGeneration !== generation) return + await Promise.all( + children.slice(index, index + 4).map((child) => { + const branch = branches[child.id] + if (branch.pageNo === 0 && !branch.error) branch.expanded = true + return loadBranchPages(child.id) + }) + ) + } + } + + async function retryChildren(id: number) { + if (id === root.value?.id) return loadFirstTwoLevels() + if (parents.get(id) === root.value?.id) return loadBranchPages(id) + return loadChildren(id) + } + async function initialize(value: UserVO | null | undefined) { generation += 1 root.value = value?.id ? value : null @@ -104,14 +141,14 @@ export function useBrokerageTree(user: Ref) { } else if (root.value.inviter) { parents.set(id, root.value.inviter.id) } - await loadChildren(id) + await loadFirstTwoLevels() } 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) + if (branch.expanded && branch.pageNo === 0) await retryChildren(id) } async function refresh() { @@ -142,6 +179,28 @@ export function useBrokerageTree(user: Ref) { const edges: { source: number; target: number; bindUserTime?: string | number | null }[] = [] const current = root.value if (!current) return { nodes, edges } + const retained = new Set() + if (hideNoIncome.value) { + for (const branch of Object.values(branches)) { + for (const child of branch.children) { + const price = child.contributionSummary?.settledPrice + const amount = String(price) + const childBranch = branches[child.id] + const hasIncome = !/^-?\d+$/.test(amount) || BigInt(amount) > 0n + const incomplete = + childBranch.error || + childBranch.warning || + (childBranch.total > 0 && hasMore(childBranch)) + if (!hasIncome && !incomplete) continue + let id: number | undefined = child.id + // Keep each matching member's relationship path, including collapsed ancestors. + while (id !== undefined && !retained.has(id)) { + retained.add(id) + id = parents.get(id) + } + } + } + } if (current.inviter && current.inviter.id !== current.id) { nodes.push({ member: current.inviter, role: 'inviter' }) edges.push({ @@ -162,6 +221,7 @@ export function useBrokerageTree(user: Ref) { const branch = branches[id] if (!branch?.expanded) continue for (const child of branch.children) { + if (hideNoIncome.value && !retained.has(child.id)) continue if (visited.has(child.id)) continue visited.add(child.id) nodes.push({ member: child, role: 'child', branch: branches[child.id] }) @@ -194,8 +254,10 @@ export function useBrokerageTree(user: Ref) { branches, refreshing, refreshError, + hideNoIncome, hasMore, loadChildren, + retryChildren, toggleBranch, refresh } diff --git a/tests/brokerage-record-list.test.cjs b/tests/brokerage-record-list.test.cjs index 4bcbe30a..cdb6e322 100644 --- a/tests/brokerage-record-list.test.cjs +++ b/tests/brokerage-record-list.test.cjs @@ -136,11 +136,12 @@ test('贡献明细固定收益人与来源,默认已结算,重置保留固 assert.equal(h.calls[0].incomeOnly, true) h.state.queryParams.status = 0 h.state.queryParams.createTime = ['2026-11-01 00:00:00', '2026-11-11 23:59:59'] - h.state.queryParams.userId = '999' + h.state.queryParams.userNickname = '其他会员' h.state.queryParams.sourceUserId = '888' h.state.queryParams.pageNo = 4 await h.state.getList() assert.equal(h.calls.at(-1).userId, 1) + assert.equal(h.calls.at(-1).userNickname, undefined) assert.equal(h.calls.at(-1).sourceUserId, 5) assert.equal(h.calls.at(-1).status, 0) h.state.resetQuery() @@ -153,6 +154,29 @@ test('贡献明细固定收益人与来源,默认已结算,重置保留固 h.stop() }) +test('用户名搜索传递模糊匹配关键词,搜索与重置回到第一页', async () => { + const h = harness() + await flush() + assert.equal(h.calls[0].userNickname, undefined) + assert.equal(h.calls[0].userId, undefined) + h.state.queryParams.userNickname = ' 小明 ' + h.state.queryParams.sourceUserId = '5' + h.state.queryParams.pageNo = 3 + h.state.handleQuery() + await flush() + assert.equal(h.calls.at(-1).userNickname, '小明') + assert.equal(h.calls.at(-1).userId, undefined) + assert.equal(h.calls.at(-1).sourceUserId, '5') + assert.equal(h.calls.at(-1).pageNo, 1) + h.state.resetQuery() + await flush() + assert.equal(h.state.queryParams.userNickname, '') + assert.equal(h.calls.at(-1).userNickname, undefined) + assert.equal(h.calls.at(-1).sourceUserId, undefined) + assert.equal(h.calls.at(-1).pageNo, 1) + h.stop() +}) + test('真实后台分页支持超过2000条记录,清空状态查询全部并保留零状态', async () => { const h = harness({}, async ({ pageNo }) => ({ list: [{ id: pageNo, price: 100 }], total: 3001 })) await flush() diff --git a/tests/member-user-brokerage.test.cjs b/tests/member-user-brokerage.test.cjs index 0bf38d3e..d9fed7c9 100644 --- a/tests/member-user-brokerage.test.cjs +++ b/tests/member-user-brokerage.test.cjs @@ -39,7 +39,7 @@ const deferred = () => { } const flush = async () => { await vue.nextTick() - for (let index = 0; index < 8; index++) await Promise.resolve() + await new Promise(setImmediate) } function harness(overrides = {}, initial = member()) { @@ -109,6 +109,9 @@ test('会员列表与详情模板可编译,邀请人紧跟禁用列,关系 assert.match(detail, /label="分销关系" name="brokerage" lazy/) assert.match(detail, /defineAsyncComponent\(\(\) => import\('\.\/UserBrokerageTree.vue'\)\)/) assert.match(detail, /label="推广用户" lazy/) + const tree = fs.readFileSync(path.join(detailDirectory, 'UserBrokerageTree.vue'), 'utf8') + assert.match(tree, /v-model="hideNoIncome"/) + assert.match(tree, /隐藏无收益下级/) }) test('等待会员资料时不查询,未绑定邀请人仍展示当前会员', async () => { @@ -121,7 +124,7 @@ test('等待会员资料时不查询,未绑定邀请人仍展示当前会员', h.stop() }) -test('直属下级按20人分页,去重后继续正确推进页码', async () => { +test('首次自动加载完整直属下级,按20人分页并去重', async () => { const h = harness({ getBrokerageChildrenPage: async ({ pageNo }) => pageNo === 1 @@ -130,7 +133,6 @@ test('直属下级按20人分页,去重后继续正确推进页码', async () }) 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) @@ -141,6 +143,121 @@ test('直属下级按20人分页,去重后继续正确推进页码', async () h.stop() }) +test('用户300自动展示43个一级与第30名一级会员名下的13个二级', async () => { + const directChildren = Array.from({ length: 43 }, (_, index) => child(403 - index, 300, index === 29 ? 13 : 0)) + const secondChildren = Array.from({ length: 13 }, (_, index) => child(1000 + index, 374)) + const h = harness({ + getBrokerageChildrenPage: async ({ bindUserId, pageNo, pageSize }) => { + const list = bindUserId === 300 ? directChildren : secondChildren + return { list: list.slice((pageNo - 1) * pageSize, pageNo * pageSize), total: list.length } + } + }, member(300)) + await flush() + assert.equal(h.tree.branches[300].children.length, 43) + assert.equal(h.tree.branches[374].children.length, 13) + assert.equal(h.tree.branches[374].expanded, true) + assert.deepEqual(nodeIds(h.tree), [90, 300, ...directChildren.map(({ id }) => id), ...secondChildren.map(({ id }) => id)]) + assert.deepEqual(h.calls.map(({ bindUserId, pageNo }) => [bindUserId, pageNo]), [ + [300, 1], [300, 2], [300, 3], [374, 1] + ]) + assert.ok(h.calls.every(({ contributionUserId }) => contributionUserId === 300)) + h.stop() +}) + +test('二级分支自动加载完整分页,三级继续由用户展开', async () => { + const secondChildren = Array.from({ length: 25 }, (_, index) => child(100 + index, 2, index === 0 ? 1 : 0)) + const h = harness({ + getBrokerageChildrenPage: async ({ bindUserId, pageNo, pageSize }) => { + if (bindUserId === 1) return { list: [child(2, 1, 25)], total: 1 } + if (bindUserId === 2) return { + list: secondChildren.slice((pageNo - 1) * pageSize, pageNo * pageSize), total: 25 + } + return { list: [child(200, 100)], total: 1 } + } + }) + await flush() + assert.equal(h.tree.branches[2].children.length, 25) + assert.equal(h.tree.branches[2].expanded, true) + assert.equal(h.tree.branches[100].expanded, false) + assert.equal(h.calls.some(({ bindUserId }) => bindUserId === 100), false) + await h.tree.toggleBranch(100) + assert.ok(nodeIds(h.tree).includes(200)) + assert.equal(h.calls.at(-1).contributionUserId, 1) + h.stop() +}) + +test('隐藏无收益下级按当前会员的已结算贡献筛选,保留关系路径、缺失金额和大数', async () => { + const contribution = (value, pendingPrice = 0) => ({ settledPrice: value, pendingPrice }) + const directChildren = [ + { ...child(2, 1, 1), contributionSummary: contribution(0) }, + { ...child(4, 1), brokerageSummary: contribution('9223372036854775807'), contributionSummary: contribution('0') }, + { ...child(5, 1), brokerageSummary: contribution(0), contributionSummary: contribution('9223372036854775807') }, + child(6, 1), + { ...child(7, 1), contributionSummary: contribution(0, 100) } + ] + const h = harness({ + getBrokerageChildrenPage: async ({ bindUserId }) => bindUserId === 1 + ? { list: directChildren, total: directChildren.length } + : { list: [{ ...child(3, 2), contributionSummary: contribution(1) }], total: 1 } + }) + await flush() + assert.equal(h.tree.hideNoIncome.value, false) + const allNodes = nodeIds(h.tree) + assert.deepEqual(allNodes, [90, 1, 2, 4, 5, 6, 7, 3]) + const requestCount = h.calls.length + h.tree.hideNoIncome.value = true + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 5, 6, 3]) + assert.deepEqual(Array.from(h.tree.graph.value.edges, ({ source, target }) => [source, target]), [ + [90, 1], [1, 2], [1, 5], [1, 6], [2, 3] + ]) + await h.tree.toggleBranch(2) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 5, 6]) + await h.tree.toggleBranch(2) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 5, 6, 3]) + h.tree.hideNoIncome.value = false + assert.deepEqual(nodeIds(h.tree), allNodes) + assert.equal(h.calls.length, requestCount) + h.stop() +}) + +test('筛选时保留加载失败的零贡献分支,重试后显示有贡献的二级', async () => { + let attempts = 0 + const h = harness({ + getBrokerageChildrenPage: async ({ bindUserId }) => { + if (bindUserId === 1) return { + list: [{ ...child(2, 1, 1), contributionSummary: { settledPrice: 0 } }], total: 1 + } + if (++attempts === 1) throw new Error('offline') + return { list: [{ ...child(3, 2), contributionSummary: { settledPrice: 100 } }], total: 1 } + } + }) + await flush() + h.tree.hideNoIncome.value = true + assert.match(h.tree.branches[2].error, /重试/) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2]) + await h.tree.retryChildren(2) + assert.equal(h.tree.branches[2].error, '') + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3]) + h.stop() +}) + +test('筛选时保留尚未加载的深层关系,展开后仍按根会员贡献判断', async () => { + const h = harness({ + getBrokerageChildrenPage: async ({ bindUserId }) => ({ + list: [{ ...child(bindUserId + 1, bindUserId, bindUserId < 3 ? 1 : 0), contributionSummary: { settledPrice: bindUserId === 3 ? '100' : '0' } }], + total: 1 + }) + }) + await flush() + h.tree.hideNoIncome.value = true + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3]) + assert.equal(h.tree.branches[3].pageNo, 0) + await h.tree.toggleBranch(3) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3, 4]) + assert.ok(h.calls.every(({ contributionUserId }) => contributionUserId === 1)) + h.stop() +}) + test('可展开超过三级,收起后再次展开复用已加载的分支与展开状态', async () => { const h = harness({ getBrokerageChildrenPage: async ({ bindUserId }) => ({ @@ -149,7 +266,9 @@ test('可展开超过三级,收起后再次展开复用已加载的分支与 }) }) await flush() - await h.tree.toggleBranch(2) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3]) + assert.equal(h.tree.branches[2].expanded, true) + assert.equal(h.tree.branches[3].expanded, false) await h.tree.toggleBranch(3) assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3, 4]) await h.tree.toggleBranch(2) @@ -171,7 +290,6 @@ test('分支失败后可重试原页,已有节点继续显示', async () => { } }) 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]) @@ -184,15 +302,24 @@ test('分支失败后可重试原页,已有节点继续显示', async () => { test('初次加载失败可重试,并发重复点击只发出一次请求', async () => { const pending = deferred() - const h = harness({ getBrokerageChildrenPage: () => pending.promise }) + let recovered = false + const h = harness({ + getBrokerageChildrenPage: ({ bindUserId }) => { + if (!recovered) return pending.promise + return Promise.resolve(bindUserId === 1 + ? { list: [child(2, 1, 1)], total: 1 } + : { list: [child(3, 2)], total: 1 }) + } + }) 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]) + recovered = true + await h.tree.retryChildren(1) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3]) + assert.equal(h.calls.length, 3) h.stop() }) @@ -266,9 +393,11 @@ test('历史循环、错误父节点和重复数据不会形成环或重复节 : { list: [child(1, 2), child(3, 2)], total: 2 } }) await flush() - assert.deepEqual(nodeIds(h.tree), [90, 1, 2]) + assert.deepEqual(nodeIds(h.tree), [90, 1, 2, 3]) assert.match(h.tree.branches[1].warning, /异常/) - assert.equal(h.tree.graph.value.nodes.at(-1).member.memberExists, false) + assert.equal(h.tree.graph.value.nodes.find(({ member }) => member.id === 2).member.memberExists, false) + 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]) assert.match(h.tree.branches[2].warning, /异常/) @@ -286,9 +415,14 @@ test('会员邀请自己时显示异常提示,组件卸载后忽略请求结 assert.equal(h.tree.branches[2], undefined) }) -test('实际Dagre布局在宽分支和三级关系中无节点重叠,节点详情跳转使用内部编号', async () => { +test('Dagre布局无节点重叠,筛选移除节点后重新适应画布,详情跳转使用内部编号', async () => { const h = harness({ - getBrokerageChildrenPage: async () => ({ list: [child(2, 1, 1), child(3, 1)], total: 2 }) + getBrokerageChildrenPage: async ({ bindUserId }) => bindUserId === 1 + ? { list: [ + { ...child(2, 1, 1), contributionSummary: { settledPrice: 0 } }, + { ...child(3, 1), contributionSummary: { settledPrice: 0 } } + ], total: 2 } + : { list: [{ ...child(4, 2), contributionSummary: { settledPrice: 100 } }], total: 1 } }) await flush() const { descriptor } = parse( @@ -296,6 +430,7 @@ test('实际Dagre布局在宽分支和三级关系中无节点重叠,节点详 ) const props = vue.reactive({ user: member(), active: true }) const routes = [] + const fitCalls = [] const context = { exports: {}, defineProps: () => props, @@ -309,7 +444,7 @@ test('实际Dagre布局在宽分支和三级关系中无节点重叠,节点详 ConnectionMode: { Strict: 'strict' }, useVueFlow: () => ({ onNodesInitialized: () => {}, - fitView: async () => {}, + fitView: async (options) => { fitCalls.push(options) }, zoomIn: () => {}, zoomOut: () => {} }) @@ -331,7 +466,7 @@ test('实际Dagre布局在宽分支和三级关系中无节点重叠,节点详 ) ) const nodes = context.state.layout.value.nodes - assert.equal(nodes.length, 4) + assert.equal(nodes.length, 5) assert.ok(nodes.every((node) => node.style.pointerEvents === 'all')) assert.ok( context.state.layout.value.edges.every( @@ -365,6 +500,16 @@ test('实际Dagre布局在宽分支和三级关系中无节点重叠,节点详 context.state.openDetail(2) assert.equal(routes[0].params.id, 2) assert.equal(routes[0].name, 'MemberUserDetail') + assert.equal(fitCalls.length, 0) + h.tree.hideNoIncome.value = true + await flush() + assert.deepEqual(Array.from(context.state.layout.value.nodes, ({ id }) => id), ['90', '1', '2', '4']) + assert.equal(fitCalls.length, 1) + assert.equal(fitCalls[0].padding, 0.18) + h.tree.hideNoIncome.value = false + await flush() + assert.equal(context.state.layout.value.nodes.length, 5) + assert.equal(fitCalls.length, 2) scope.stop() h.stop() })