This commit is contained in:
dzc committed 2026-11-11 19:38:55 +08:00
1 parent 25b7f38df8
commit f744e2b9b8
16 files changed
+3425 -126

No files matched your search

+91
View File
@@ -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<PushTask>({ 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 })
+148
View File
@@ -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<VersionPackage, 'id'> {
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<AppVersion>({ url: '/member/app-version/get', params: { id } })
/** 新建草稿,发布动作由单独接口确认。 */
export const createVersion = (data: VersionSaveRequest) =>
request.post<number>({ url: '/member/app-version/create', data })
/** 保存草稿或下架版本。 */
export const updateVersion = (data: VersionSaveRequest) =>
request.put<boolean>({ url: '/member/app-version/update', data })
/** 删除允许删除的发布记录。 */
export const deleteVersion = (id: number) =>
request.delete<boolean>({ url: '/member/app-version/delete', params: { id } })
/** 将草稿或下架版本发布到保存的目标人群。 */
export const publishVersion = (id: number) =>
request.put<boolean>({ url: '/member/app-version/publish', params: { id } })
/** 下架已发布版本,停止向客户端提供该版本。 */
export const offlineVersion = (id: number) =>
request.put<boolean>({ url: '/member/app-version/offline', params: { id } })
/** 将当前灰测版本提升为全量发布。 */
export const promoteVersion = (id: number) =>
request.put<boolean>({ 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
}
@@ -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
+642
View File
@@ -0,0 +1,642 @@
<template>
<el-dialog
v-model="visible"
title="手动推送"
class="push-send-dialog"
width="min(1040px, calc(100vw - 32px))"
append-to-body
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
>
<el-form
ref="formRef"
class="push-send-form"
:model="formData"
:rules="formRules"
label-width="88px"
:disabled="submitting"
@submit.prevent
>
<el-form-item label="推送对象" prop="targetType">
<el-radio-group v-model="formData.targetType" class="push-target-group">
<el-radio-button value="USER">指定会员</el-radio-button>
<el-radio-button value="ALL">全部已绑定设备会员</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="推送标题" prop="title">
<el-input
v-model.trim="formData.title"
placeholder="请输入推送标题"
maxlength="40"
show-word-limit
/>
</el-form-item>
<el-form-item label="推送内容" prop="content">
<el-input
v-model.trim="formData.content"
type="textarea"
:rows="3"
resize="vertical"
placeholder="请输入推送内容"
maxlength="100"
show-word-limit
/>
</el-form-item>
<el-form-item
v-if="formData.targetType === 'USER'"
label="接收会员"
prop="memberIds"
label-position="top"
label-width="auto"
class="push-recipient-field"
>
<div class="push-member-picker">
<div class="push-member-filters">
<label class="push-member-filter">
<span>用户昵称</span>
<el-input
v-model.trim="memberQuery.nickname"
clearable
aria-label="用户昵称"
placeholder="请输入用户昵称"
@keyup.enter="searchMembers"
/>
</label>
<label class="push-member-filter">
<span>用户ID</span>
<el-input
v-model.trim="memberQuery.userCode"
clearable
aria-label="App 用户ID"
placeholder="App 我的页面显示的 ID"
@keyup.enter="searchMembers"
/>
</label>
<label class="push-member-filter">
<span>手机号</span>
<el-input
v-model.trim="memberQuery.mobile"
clearable
aria-label="手机号"
placeholder="请输入手机号"
@keyup.enter="searchMembers"
/>
</label>
<div class="push-member-filter-actions">
<el-button :loading="membersLoading" @click="searchMembers">
<Icon icon="ep:search" class="mr-5px" aria-hidden="true" />搜索
</el-button>
<el-button @click="resetMemberQuery">
<Icon icon="ep:refresh" class="mr-5px" aria-hidden="true" />重置
</el-button>
</div>
</div>
<el-alert
v-if="membersError"
:title="membersError"
type="error"
:closable="false"
show-icon
class="mb-12px"
>
<el-button link type="primary" @click="loadMembers">重试</el-button>
</el-alert>
<el-table
ref="tableRef"
v-loading="membersLoading"
class="push-member-table"
:data="members"
row-key="id"
height="264"
border
stripe
flexible
table-layout="fixed"
scrollbar-always-on
:scrollbar-tabindex="0"
@select="updateSelection"
@select-all="updateSelection"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="用户编号" prop="id" width="100" align="center" />
<el-table-column label="用户ID" prop="userCode" width="120" align="center">
<template #default="{ row }">{{ row.userCode || '—' }}</template>
</el-table-column>
<el-table-column label="昵称" prop="nickname" min-width="180" align="center">
<template #default="{ row }">{{ row.nickname || '未设置昵称' }}</template>
</el-table-column>
<el-table-column label="手机号" prop="mobile" min-width="150" align="center">
<template #default="{ row }">{{ row.mobile || '—' }}</template>
</el-table-column>
<el-table-column label="推送设备" prop="deviceCount" width="140" align="center">
<template #default="{ row }">
<el-tag
:type="row.deviceCount > 0 ? 'success' : 'info'"
effect="plain"
size="small"
>
{{ row.deviceCount > 0 ? row.deviceCount + ' 台已绑定' : '未绑定设备' }}
</el-tag>
</template>
</el-table-column>
<template #empty><el-empty description="没有匹配的会员" :image-size="48" /></template>
</el-table>
<div class="push-member-toolbar">
<div class="push-member-selection" aria-live="polite">
<span
>已选 <strong>{{ selectedMembers.length }}</strong> 位会员</span
>
<el-button
link
type="primary"
:disabled="submitting || !selectedMembers.length"
@click="clearSelection"
>清空</el-button
>
<span class="push-member-selection-hint">支持跨页选择</span>
</div>
<el-pagination
v-if="membersTotal > 0"
v-model:current-page="memberQuery.pageNo"
v-model:page-size="memberQuery.pageSize"
background
:pager-count="5"
:total="membersTotal"
:page-sizes="[10, 20, 50, 100]"
:disabled="submitting"
layout="total, sizes, prev, pager, next"
@size-change="changeMemberPageSize"
@current-change="loadMembers"
/>
</div>
<el-scrollbar v-if="selectedMembers.length" max-height="76px" :tabindex="0">
<div class="push-selected-members">
<el-tag
v-for="member in selectedMembers"
:key="member.id"
closable
@close="removeMember(member.id)"
>
{{ member.nickname || '未设置昵称' }} · {{ member.userCode || member.id }}
</el-tag>
</div>
</el-scrollbar>
</div>
</el-form-item>
<el-form-item v-else label="接收范围">
<div class="push-all-range">当前租户所有已绑定推送设备的会员</div>
</el-form-item>
</el-form>
<el-alert
v-if="submitError"
:title="submitError"
type="error"
:closable="false"
show-icon
class="mt-12px"
/>
<template #footer>
<div class="push-send-footer">
<span class="push-send-hint">点击通知将打开 App 消息页</span>
<div class="push-send-actions">
<el-button :disabled="submitting" @click="visible = false">取消</el-button>
<el-button
v-hasPermi="['member:push:send']"
type="primary"
:loading="submitting"
@click="submit"
>
{{ submitting ? '正在提交' : '发送推送' }}
</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules, TableInstance } from 'element-plus'
import { getPushMemberPage, sendPush, type PushMember, type PushSendRequest } from '@/api/app/push'
import { generateUUID } from '@/utils'
defineOptions({ name: 'AppPushSendForm' })
const emit = defineEmits<{ success: [taskId: number] }>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const submitError = ref('')
const formRef = ref<FormInstance>()
const tableRef = ref<TableInstance>()
const formData = reactive<PushSendRequest>({
requestId: '',
targetType: 'USER',
memberIds: [],
title: '',
content: ''
})
const formRules: FormRules = {
title: [
{ required: true, whitespace: true, message: '请输入推送标题', trigger: 'blur' },
{ max: 40, message: '标题最多 40 个字', trigger: 'blur' }
],
content: [
{ required: true, whitespace: true, message: '请输入推送内容', trigger: 'blur' },
{ max: 100, message: '内容最多 100 个字', trigger: 'blur' }
],
memberIds: [
{ type: 'array', required: true, min: 1, message: '请选择接收会员', trigger: 'change' }
]
}
const memberQuery = reactive({ pageNo: 1, pageSize: 10, nickname: '', userCode: '', mobile: '' })
const members = ref<PushMember[]>([])
const selectedMembers = ref<PushMember[]>([])
const membersTotal = ref(0)
const membersLoading = ref(false)
const membersError = ref('')
let memberRequestSequence = 0
let submittedPayload = ''
const restoreSelection = async () => {
await nextTick()
tableRef.value?.clearSelection()
const selectedIds = new Set(formData.memberIds)
members.value.forEach((member) => {
if (selectedIds.has(member.id)) tableRef.value?.toggleRowSelection(member, true)
})
}
const loadMembers = async () => {
const sequence = ++memberRequestSequence
membersLoading.value = true
membersError.value = ''
try {
const data = await getPushMemberPage(memberQuery)
if (sequence !== memberRequestSequence || !visible.value) return
members.value = data.list
membersTotal.value = data.total
await restoreSelection()
} catch {
if (sequence === memberRequestSequence) membersError.value = '会员列表加载失败,请重试'
} finally {
if (sequence === memberRequestSequence) membersLoading.value = false
}
}
const updateSelection = (pageMembers: PushMember[]) => {
const pageIds = new Set(members.value.map((member) => member.id))
selectedMembers.value = [
...selectedMembers.value.filter((member) => !pageIds.has(member.id)),
...pageMembers
]
formData.memberIds = selectedMembers.value.map((member) => member.id)
void formRef.value?.validateField('memberIds').catch(() => {})
}
const removeMember = (id: number) => {
if (submitting.value) return
selectedMembers.value = selectedMembers.value.filter((member) => member.id !== id)
formData.memberIds = selectedMembers.value.map((member) => member.id)
void restoreSelection()
}
const clearSelection = () => {
selectedMembers.value = []
formData.memberIds = []
void restoreSelection()
}
const changeMemberPageSize = () => {
memberQuery.pageNo = 1
void loadMembers()
}
const searchMembers = () => {
memberQuery.pageNo = 1
void loadMembers()
}
const resetMemberQuery = () => {
Object.assign(memberQuery, { pageNo: 1, nickname: '', userCode: '', mobile: '' })
void loadMembers()
}
const open = () => {
if (submitting.value) return
Object.assign(formData, {
requestId: '',
targetType: 'USER',
memberIds: [],
title: '',
content: ''
})
selectedMembers.value = []
members.value = []
membersTotal.value = 0
submitError.value = ''
submittedPayload = ''
visible.value = true
resetMemberQuery()
void nextTick(() => formRef.value?.clearValidate())
}
const submit = async () => {
if (submitting.value) return
submitting.value = true
try {
if (!(await formRef.value?.validate().catch(() => false))) return
const payload = {
targetType: formData.targetType,
memberIds:
formData.targetType === 'USER' ? [...formData.memberIds].sort((a, b) => a - b) : [],
title: formData.title.trim(),
content: formData.content.trim()
}
// 请求结果不明时,原样重试继续使用同一编号,防止重复创建推送。
const snapshot = JSON.stringify(payload)
if (snapshot !== submittedPayload) {
formData.requestId = generateUUID()
submittedPayload = snapshot
}
submitError.value = ''
const result = await sendPush({ ...payload, requestId: formData.requestId })
message.success(`推送任务 ${result.taskId} 已创建`)
visible.value = false
emit('success', result.taskId)
} catch {
submitError.value = '提交结果暂未确认,可保留当前内容点击发送重试。'
} finally {
submitting.value = false
}
}
watch(
() => formData.targetType,
async (targetType) => {
await nextTick()
formRef.value?.clearValidate('memberIds')
if (visible.value && targetType === 'USER') void restoreSelection()
}
)
watch(visible, (value) => {
if (!value) {
memberRequestSequence++
membersLoading.value = false
}
})
onDeactivated(() => {
visible.value = false
})
onBeforeUnmount(() => {
memberRequestSequence++
})
defineExpose({ open })
</script>
<style lang="scss">
.push-send-dialog.el-dialog {
display: flex;
max-height: calc(100dvh - 48px);
padding: 0;
margin: 24px auto;
overflow: hidden;
border-radius: 10px;
flex-direction: column;
.el-dialog__header {
padding: 20px 24px;
margin-right: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
.el-dialog__title {
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.el-dialog__headerbtn {
top: 12px;
right: 16px;
width: 40px;
height: 40px;
}
.el-dialog__body {
min-height: 0;
padding: 24px;
overflow-y: auto;
}
.el-dialog__footer {
padding: 16px 24px;
border-top: 1px solid var(--el-border-color-lighter);
flex-shrink: 0;
}
.push-send-form > .el-form-item {
margin-bottom: 20px;
}
.push-send-form > .el-form-item:last-child {
margin-bottom: 4px;
}
.push-target-group .el-radio-button__inner {
padding: 9px 18px;
}
.push-recipient-field {
padding-top: 4px;
> .el-form-item__label {
margin-bottom: 12px;
color: var(--el-text-color-primary);
}
> .el-form-item__content {
display: block;
min-width: 0;
}
}
.push-member-picker {
width: 100%;
min-width: 0;
}
.push-member-filters {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
gap: 12px;
align-items: end;
margin-bottom: 16px;
}
.push-member-filter {
display: grid;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--el-text-color-regular);
gap: 6px;
}
.push-member-filter-actions,
.push-send-actions {
display: flex;
gap: 8px;
> .el-button + .el-button {
margin-left: 0;
}
}
.push-member-table {
--el-table-header-bg-color: var(--el-fill-color-light);
width: 100%;
.el-table__cell {
padding: 10px 0;
}
.cell {
line-height: 22px;
word-break: normal;
white-space: normal;
overflow-wrap: anywhere;
}
.el-table__header .cell {
color: var(--el-text-color-regular);
white-space: nowrap;
}
.el-tag {
color: var(--el-text-color-regular);
}
}
.push-member-toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
align-items: center;
justify-content: space-between;
padding: 16px 0 12px;
> .el-pagination {
max-width: 100%;
margin-left: auto;
}
}
.push-member-selection {
display: flex;
font-size: 13px;
line-height: 22px;
color: var(--el-text-color-regular);
flex-wrap: wrap;
gap: 8px;
align-items: center;
strong {
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.push-member-selection-hint {
padding-left: 8px;
border-left: 1px solid var(--el-border-color);
}
.push-selected-members {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 4px;
.el-tag {
height: auto;
max-width: 100%;
min-height: 24px;
white-space: normal;
}
.el-tag__content {
min-width: 0;
overflow-wrap: anywhere;
line-height: 20px;
white-space: normal;
}
.el-tag__close {
flex-shrink: 0;
}
}
.push-all-range {
width: 100%;
padding: 12px 16px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--el-border-radius-base);
}
.push-send-footer {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.push-send-hint {
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-regular);
text-align: left;
}
/* stylelint-disable-next-line order/order */
@media (width <= 800px) {
.push-member-filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (width <= 600px) {
max-height: calc(100dvh - 32px);
margin: 16px auto;
.el-dialog__header {
padding: 16px;
}
.el-dialog__body,
.el-dialog__footer {
padding: 16px;
}
.push-member-filters {
grid-template-columns: minmax(0, 1fr);
}
.push-member-toolbar > .el-pagination {
overflow-x: auto;
}
.push-send-footer {
align-items: flex-end;
}
.push-send-actions {
flex-shrink: 0;
}
}
}
</style>
+232
View File
@@ -0,0 +1,232 @@
<template>
<el-dialog
v-model="visible"
title="推送任务详情"
width="min(1180px, 96vw)"
append-to-body
destroy-on-close
>
<div v-loading="loading" class="min-h-160px">
<el-alert
v-if="taskError"
:title="taskError"
type="error"
:closable="false"
show-icon
class="mb-12px"
>
<el-button link type="primary" @click="refreshTask()">重试</el-button>
</el-alert>
<template v-if="task">
<el-descriptions :column="2" border label-width="100px">
<el-descriptions-item label="任务编号">{{ task.id }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<el-tag :type="getTaskStatus(task.status)?.type">{{
getTaskStatus(task.status)?.label || task.status
}}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="推送来源">{{
task.source === 'AUTO' ? '消息自动推送' : '后台手动推送'
}}</el-descriptions-item>
<el-descriptions-item label="推送对象">{{
task.targetType === 'ALL' ? '全部已绑定设备会员' : '指定会员'
}}</el-descriptions-item>
<el-descriptions-item label="推送标题" :span="2">{{ task.title }}</el-descriptions-item>
<el-descriptions-item label="推送内容" :span="2"
><span class="whitespace-pre-wrap break-all">{{
task.content
}}</span></el-descriptions-item
>
<el-descriptions-item label="接收规模"
>{{ task.totalMembers }} 位会员 / {{ task.totalDevices }} 台设备</el-descriptions-item
>
<el-descriptions-item label="处理进度"
>{{ task.processedCount }} / {{ task.totalDevices }} 台设备</el-descriptions-item
>
<el-descriptions-item label="提交结果" :span="2">
已提交极光 {{ task.submittedCount }} 台,失败 {{ task.failedCount }} 台,跳过
{{ task.skippedCount }} 台
</el-descriptions-item>
<el-descriptions-item v-if="task.source === 'AUTO'" label="业务消息编号">{{
task.messageId || '—'
}}</el-descriptions-item>
<el-descriptions-item v-if="task.source === 'AUTO'" label="发送人 / 接收人"
>{{ task.senderId || '—' }} / {{ task.receiverId || '—' }}</el-descriptions-item
>
<el-descriptions-item label="创建时间">{{
formatDate(new Date(task.createTime))
}}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{
task.finishTime ? formatDate(new Date(task.finishTime)) : '—'
}}</el-descriptions-item>
<el-descriptions-item v-if="task.lastError" label="处理说明" :span="2"
><span class="break-all">{{ task.lastError }}</span></el-descriptions-item
>
</el-descriptions>
</template>
</div>
<div class="flex items-center justify-between mt-20px mb-12px">
<h3 class="m-0 text-15px font-600">设备提交记录</h3>
<el-button :loading="recordsLoading" @click="getRecords()"
><Icon icon="ep:refresh" class="mr-5px" />刷新明细</el-button
>
</div>
<el-alert
v-if="recordsError"
:title="recordsError"
type="error"
:closable="false"
show-icon
class="mb-12px"
/>
<el-table
v-loading="recordsLoading"
:data="records"
stripe
show-overflow-tooltip
max-height="350"
>
<el-table-column label="接收会员" min-width="150">
<template #default="{ row }">
<div>{{ row.nickname || '未设置昵称' }}</div>
<div class="text-12px text-[var(--el-text-color-secondary)]"
>ID {{ row.userCode || row.memberId }}</div
>
</template>
</el-table-column>
<el-table-column label="平台" prop="platform" width="85" />
<el-table-column label="RegistrationID" prop="registrationId" min-width="205" />
<el-table-column label="提交结果" width="120" align="center">
<template #default="{ row }">
<el-tag :type="getRecordStatus(row.status)?.type">{{
getRecordStatus(row.status)?.label || row.status
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="提交次数" prop="attemptCount" width="88" align="center" />
<el-table-column label="极光消息编号" prop="jpushMsgId" min-width="160" />
<el-table-column label="请求标识" prop="cid" min-width="150" />
<el-table-column label="失败 / 跳过原因" min-width="220">
<template #default="{ row }">{{
[row.errorCode, row.errorMessage].filter(Boolean).join(' · ') || '—'
}}</template>
</el-table-column>
<el-table-column label="提交时间" width="180">
<template #default="{ row }">{{
row.sendTime ? formatDate(new Date(row.sendTime)) : '—'
}}</template>
</el-table-column>
<template #empty><el-empty description="暂无设备提交记录" :image-size="48" /></template>
</el-table>
<div class="flow-root">
<Pagination
v-model:page="recordQuery.pageNo"
v-model:limit="recordQuery.pageSize"
:total="recordsTotal"
@pagination="getRecords()"
/>
</div>
<template #footer><el-button @click="visible = false">关闭</el-button></template>
</el-dialog>
</template>
<script setup lang="ts">
import { getPushRecordPage, getPushTask, type PushRecord, type PushTask } from '@/api/app/push'
import { formatDate } from '@/utils/formatTime'
import { getRecordStatus, getTaskStatus, isTaskActive } from './push.data'
defineOptions({ name: 'AppPushTaskDetail' })
const emit = defineEmits<{ updated: [] }>()
const visible = ref(false)
const loading = ref(false)
const taskError = ref('')
const task = ref<PushTask>()
const recordsLoading = ref(false)
const recordsError = ref('')
const records = ref<PushRecord[]>([])
const recordsTotal = ref(0)
const recordQuery = reactive({ taskId: 0, pageNo: 1, pageSize: 10 })
let taskRequestSequence = 0
let recordRequestSequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
const getRecords = async (silent = false) => {
if (!visible.value) return
const sequence = ++recordRequestSequence
if (!silent) recordsLoading.value = true
try {
const data = await getPushRecordPage(recordQuery)
if (sequence !== recordRequestSequence || !visible.value) return
records.value = data.list
recordsTotal.value = data.total
recordsError.value = ''
} catch {
if (sequence === recordRequestSequence)
recordsError.value = '设备提交记录加载失败,请点击刷新明细重试'
} finally {
if (sequence === recordRequestSequence) recordsLoading.value = false
}
}
const refreshTask = async (silent = false) => {
if (!visible.value) return
clearTimeout(timer)
const sequence = ++taskRequestSequence
if (!silent) loading.value = true
try {
const data = await getPushTask(recordQuery.taskId)
if (sequence !== taskRequestSequence || !visible.value) return
const previous = task.value
task.value = data
taskError.value = ''
if (
previous &&
(previous.processedCount !== data.processedCount ||
previous.totalDevices !== data.totalDevices ||
previous.status !== data.status)
) {
void getRecords(true)
}
if (isTaskActive(data.status)) {
timer = setTimeout(() => refreshTask(true), 2000)
} else if (previous && isTaskActive(previous.status)) {
emit('updated')
}
} catch {
if (sequence === taskRequestSequence) taskError.value = '任务详情加载失败,请重试查询'
} finally {
if (sequence === taskRequestSequence) loading.value = false
}
}
const stopPolling = () => {
clearTimeout(timer)
taskRequestSequence++
recordRequestSequence++
loading.value = false
recordsLoading.value = false
}
const open = (taskId: number) => {
stopPolling()
task.value = undefined
taskError.value = ''
recordsError.value = ''
records.value = []
recordsTotal.value = 0
Object.assign(recordQuery, { taskId, pageNo: 1 })
visible.value = true
void refreshTask()
void getRecords()
}
watch(visible, (value) => {
if (!value) stopPolling()
})
onDeactivated(() => {
visible.value = false
})
onBeforeUnmount(stopPolling)
defineExpose({ open })
</script>
+217
View File
@@ -0,0 +1,217 @@
<template>
<ContentWrap>
<el-form
ref="queryFormRef"
:model="queryParams"
:inline="true"
class="-mb-15px"
label-width="80px"
@submit.prevent="handleQuery"
>
<el-form-item label="推送来源" prop="source">
<el-select v-model="queryParams.source" clearable placeholder="全部来源" class="!w-180px">
<el-option label="消息自动推送" value="AUTO" />
<el-option label="后台手动推送" value="MANUAL" />
</el-select>
</el-form-item>
<el-form-item label="会员编号" prop="memberId">
<el-input-number
v-model="queryParams.memberId"
:min="1"
:precision="0"
:controls="false"
placeholder="请输入内部会员编号"
class="!w-220px"
/>
</el-form-item>
<el-form-item label="任务状态" prop="status">
<el-select v-model="queryParams.status" clearable placeholder="全部状态" class="!w-180px">
<el-option
v-for="(option, value) in taskStatuses"
:key="value"
:label="option.label"
:value="value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('2000-01-01 00:00:00'), new Date('2000-01-01 23:59:59')]"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button native-type="submit"><Icon icon="ep:search" class="mr-5px" />搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
<el-button v-hasPermi="['member:push:send']" type="primary" @click="sendFormRef?.open()">
<Icon icon="ep:promotion" class="mr-5px" />手动推送
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-alert
v-if="listError"
:title="listError"
type="error"
:closable="false"
show-icon
class="mb-12px"
>
<el-button link type="primary" @click="getList()">重试</el-button>
</el-alert>
<el-table v-loading="loading" :data="list" stripe show-overflow-tooltip>
<el-table-column label="任务编号" prop="id" width="100" />
<el-table-column label="来源 / 目标" min-width="155">
<template #default="{ row }">
<div>{{ row.source === 'AUTO' ? '消息自动推送' : '后台手动推送' }}</div>
<div class="text-12px text-[var(--el-text-color-secondary)]">
{{ row.targetType === 'ALL' ? '全部已绑定设备会员' : '指定会员' }}
</div>
</template>
</el-table-column>
<el-table-column label="推送内容" min-width="270">
<template #default="{ row }">
<div>{{ row.title }}</div>
<div class="text-12px text-[var(--el-text-color-secondary)]">{{ row.content }}</div>
</template>
</el-table-column>
<el-table-column label="任务状态" width="112" align="center">
<template #default="{ row }">
<el-tag :type="getTaskStatus(row.status)?.type">{{
getTaskStatus(row.status)?.label || row.status
}}</el-tag>
</template>
</el-table-column>
<el-table-column label="处理进度" min-width="175">
<template #default="{ row }">
<div>{{ row.totalMembers }} 位会员 / {{ row.totalDevices }} 台设备</div>
<div class="text-12px text-[var(--el-text-color-secondary)]">
已处理 {{ row.processedCount }} / {{ row.totalDevices }} 台
</div>
</template>
</el-table-column>
<el-table-column label="提交结果" min-width="155">
<template #default="{ row }">
<div>已提交极光 {{ row.submittedCount }}</div>
<div class="text-12px text-[var(--el-text-color-secondary)]">
失败 {{ row.failedCount }} / 跳过 {{ row.skippedCount }}
</div>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">{{ formatDate(new Date(row.createTime)) }}</template>
</el-table-column>
<el-table-column label="操作" width="88" fixed="right" align="center">
<template #default="{ row }">
<el-button
v-hasPermi="['member:push:query']"
link
type="primary"
@click="detailRef?.open(row.id)"
>
详情
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无推送记录" :image-size="64" /></template>
</el-table>
<Pagination
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
:total="total"
@pagination="getList()"
/>
</ContentWrap>
<PushSendForm ref="sendFormRef" @success="handleSent" />
<PushTaskDetail ref="detailRef" @updated="getList(true)" />
</template>
<script setup lang="ts">
import type { FormInstance } from 'element-plus'
import { getPushTaskPage, type PushTask, type PushTaskPageRequest } from '@/api/app/push'
import { formatDate } from '@/utils/formatTime'
import { checkPermi } from '@/utils/permission'
import PushSendForm from './PushSendForm.vue'
import PushTaskDetail from './PushTaskDetail.vue'
import { getTaskStatus, isTaskActive, taskStatuses } from './push.data'
defineOptions({ name: 'AppPush' })
const queryFormRef = ref<FormInstance>()
const sendFormRef = ref<InstanceType<typeof PushSendForm>>()
const detailRef = ref<InstanceType<typeof PushTaskDetail>>()
const queryParams = reactive<PushTaskPageRequest>({
pageNo: 1,
pageSize: 10,
source: undefined,
memberId: undefined,
status: undefined,
createTime: []
})
const loading = ref(false)
const listError = ref('')
const list = ref<PushTask[]>([])
const total = ref(0)
let pageActive = true
let requestSequence = 0
let timer: ReturnType<typeof setTimeout> | undefined
const getList = async (silent = false) => {
if (!pageActive || !checkPermi(['member:push:query'])) return
clearTimeout(timer)
const sequence = ++requestSequence
if (!silent) loading.value = true
try {
const data = await getPushTaskPage(queryParams)
if (sequence !== requestSequence || !pageActive) return
list.value = data.list
total.value = data.total
listError.value = ''
if (data.list.some((task) => isTaskActive(task.status))) {
timer = setTimeout(() => getList(true), 2000)
}
} catch {
if (sequence === requestSequence) listError.value = '推送记录加载失败,请重试'
} finally {
if (sequence === requestSequence) loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
void getList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
const handleSent = (taskId: number) => {
void getList()
if (pageActive && checkPermi(['member:push:query'])) detailRef.value?.open(taskId)
}
const stopPolling = () => {
pageActive = false
requestSequence++
clearTimeout(timer)
loading.value = false
}
onMounted(() => getList())
onActivated(() => {
if (pageActive) return
pageActive = true
void getList()
})
onDeactivated(stopPolling)
onBeforeUnmount(stopPolling)
</script>
+29
View File
@@ -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<PushTaskStatus, PushStatusOption> = {
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<PushRecordStatus, PushStatusOption> = {
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]
+276
View File
@@ -0,0 +1,276 @@
<template>
<el-dialog
v-model="visible"
title="选择灰测会员"
width="min(1000px, 94vw)"
top="5vh"
append-to-body
:close-on-click-modal="false"
destroy-on-close
>
<div class="member-picker-scroll">
<div class="member-picker">
<section class="min-w-0">
<el-form class="mb-16px flex gap-8px" @submit.prevent="search">
<el-input
v-model.trim="keyword"
clearable
placeholder="昵称、手机号、App 用户ID"
aria-label="搜索灰测会员"
@clear="search"
/>
<el-button type="primary" native-type="submit" :loading="loading">搜索</el-button>
</el-form>
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
show-icon
class="mb-12px"
>
<el-button link type="primary" @click="loadMembers">重新加载</el-button>
</el-alert>
<el-table v-loading="loading" :data="rows" row-key="id" height="390">
<el-table-column width="44" align="center">
<template #header>
<el-checkbox
:model-value="allPageSelected"
:indeterminate="pagePartlySelected"
aria-label="选择当前页所有会员"
@change="togglePageSelection"
/>
</template>
<template #default="{ row }">
<el-checkbox
:model-value="selectedIds.has(row.id)"
:aria-label="`选择会员${row.nickname || '未设置昵称'},ID ${row.userCode || row.id}`"
@change="(checked) => toggleMemberSelection(row, checked)"
/>
</template>
</el-table-column>
<el-table-column label="会员" min-width="180">
<template #default="{ row }">
<div class="flex items-center gap-10px">
<el-avatar :size="32" :src="row.avatar || undefined" aria-hidden="true"
><Icon icon="ep:user"
/></el-avatar>
<div class="min-w-0">
<div class="truncate">{{ row.nickname || '未设置昵称' }}</div>
<div class="member-id">ID {{ row.userCode || row.id }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="mobile" label="手机号" min-width="124" />
<template #empty><el-empty description="没有找到可选会员" :image-size="64" /></template>
</el-table>
<Pagination
v-model:page="pageNo"
v-model:limit="pageSize"
:total="total"
@pagination="loadMembers"
/>
</section>
<aside class="selected-members">
<div class="flex items-center justify-between mb-12px">
<span class="font-600">已选 {{ selected.length }} 人</span>
<el-button link type="primary" :disabled="!selected.length" @click="clearSelected"
>清空</el-button
>
</div>
<p class="member-picker-hint mb-12px">支持跨页选择,确认后加入当前版本。</p>
<el-scrollbar max-height="360px">
<div v-for="member in selected" :key="member.id" class="selected-member">
<div class="min-w-0">
<div class="truncate">{{
member.nickname || `会员 ${member.userCode || member.id}`
}}</div>
<div class="member-id">ID {{ member.userCode || member.id }}</div>
</div>
<el-button
text
circle
:aria-label="`移除会员${member.nickname || member.userCode || member.id}`"
@click="removeMember(member.id)"
><Icon icon="ep:close"
/></el-button>
</div>
<el-empty v-if="!selected.length" description="暂未选择会员" :image-size="56" />
</el-scrollbar>
</aside>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="confirmSelection"
>确认选择 {{ selected.length ? `(${selected.length})` : '' }}</el-button
>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { getMemberPage, type GrayMember } from '@/api/app/version'
/** 灰测会员分页选择器,确认前的选择仅属于当前弹窗。 */
defineOptions({ name: 'VersionMemberSelector' })
const emit = defineEmits<{ confirm: [members: GrayMember[]] }>()
const visible = ref(false)
const loading = ref(false)
const loadError = ref('')
const keyword = ref('')
const pageNo = ref(1)
const pageSize = ref(10)
const total = ref(0)
const rows = ref<GrayMember[]>([])
const selected = ref<GrayMember[]>([])
const selectedIds = computed(() => new Set(selected.value.map((member) => member.id)))
const allPageSelected = computed(
() => rows.value.length > 0 && rows.value.every((member) => selectedIds.value.has(member.id))
)
const pagePartlySelected = computed(
() => !allPageSelected.value && rows.value.some((member) => selectedIds.value.has(member.id))
)
/** 丢弃已被新搜索或新一轮打开覆盖的异步响应。 */
let requestSequence = 0
/** 查询当前页,并保留全部已选会员。 */
const loadMembers = async () => {
const sequence = ++requestSequence
loading.value = true
loadError.value = ''
try {
const data = await getMemberPage({
pageNo: pageNo.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined
})
if (sequence !== requestSequence) return
rows.value = data.list
total.value = data.total
} catch {
if (sequence === requestSequence) loadError.value = '会员列表加载失败,请重试'
} finally {
if (sequence === requestSequence) loading.value = false
}
}
/** 单个会员的选择直接写入跨页名单,复选框由名单驱动。 */
const toggleMemberSelection = (member: GrayMember, checked: string | number | boolean) => {
if (!checked) {
removeMember(member.id)
return
}
if (selectedIds.value.has(member.id)) return
selected.value = [...selected.value, member]
}
/** 页内全选只影响当前页,保留其余页的选择。 */
const togglePageSelection = (checked: string | number | boolean) => {
const pageIds = new Set(rows.value.map((member) => member.id))
if (!checked) {
selected.value = selected.value.filter((member) => !pageIds.has(member.id))
return
}
const next = [
...selected.value,
...rows.value.filter((member) => !selectedIds.value.has(member.id))
]
selected.value = next
}
/** 从已选名单移除会员,并同步当前页勾选。 */
const removeMember = (id: number) => {
selected.value = selected.value.filter((member) => member.id !== id)
}
/** 清空全部页已选会员。 */
const clearSelected = () => {
selected.value = []
}
/** 更改关键字后从第一页重新查询。 */
const search = () => {
pageNo.value = 1
loadMembers()
}
/** 使用父表单的当前名单开启独立的选择事务。 */
const open = (members: GrayMember[]) => {
requestSequence++
selected.value = members.map((member) => ({ ...member }))
keyword.value = ''
pageNo.value = 1
rows.value = []
total.value = 0
visible.value = true
loadMembers()
}
/** 确认后一次性回传名单,取消不会改变父表单。 */
const confirmSelection = () => {
emit(
'confirm',
selected.value.map((member) => ({ ...member }))
)
visible.value = false
}
watch(visible, (value) => {
if (!value) requestSequence++
})
defineExpose({ open })
</script>
<style scoped lang="scss">
.member-picker-scroll {
max-height: calc(90vh - 130px);
overflow: auto;
}
.member-picker :deep(.el-pagination) {
float: none;
justify-content: flex-end;
max-width: 100%;
}
.member-picker {
display: grid;
grid-template-columns: minmax(0, 1fr) 260px;
gap: 24px;
}
.selected-members {
padding: 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
background: var(--el-fill-color-extra-light);
}
.member-id,
.member-picker-hint {
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-regular);
}
.selected-member {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
@media (max-width: 720px) {
.member-picker :deep(.el-pagination__total),
.member-picker :deep(.el-pagination__sizes),
.member-picker :deep(.el-pagination__jump) {
display: none;
}
.member-picker {
grid-template-columns: minmax(0, 1fr);
}
.selected-members {
max-height: 210px;
overflow: auto;
}
}
</style>
+824
View File
@@ -0,0 +1,824 @@
<template>
<el-drawer
v-model="visible"
:title="drawerTitle"
size="min(860px, 100vw)"
:before-close="beforeClose"
:close-on-click-modal="false"
:close-on-press-escape="!saving && !uploading"
:show-close="!saving && !uploading"
class="version-drawer"
destroy-on-close
>
<div v-loading="loading" class="version-form-content">
<el-alert
v-if="loadError"
:title="loadError"
type="error"
:closable="false"
show-icon
class="mb-20px"
>
<el-button link type="primary" @click="loadVersion">重新加载</el-button>
</el-alert>
<el-alert
v-if="mode === 'view' && detailStatus === 'published'"
title="该版本正在发布中,调整包文件或发布设置前请先下架。"
type="info"
:closable="false"
show-icon
class="mb-20px"
/>
<el-alert
v-if="copySource"
:title="`已复制 ${copySource} 的更新说明与发布设置,请上传新版本安装包。`"
type="info"
:closable="false"
show-icon
class="mb-20px"
/>
<el-form
v-if="!loadError"
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
:disabled="readOnly || loading || saving"
scroll-to-error
@submit.prevent
>
<section class="form-section">
<div class="section-heading">
<div class="section-icon"><Icon icon="ep:box" /></div>
<div><h3>更新包</h3><p>上传后自动读取版本信息并校验文件。</p></div>
</div>
<el-form-item label="发布目标" prop="platforms">
<el-checkbox-group
v-model="form.platforms"
class="target-platform-options"
:disabled="readOnly || uploading || apkOnly"
aria-label="选择发布目标平台"
>
<el-checkbox value="android" border>Android</el-checkbox>
<el-checkbox value="ios" border>iOS</el-checkbox>
<el-checkbox value="harmony" border>HarmonyOS</el-checkbox>
</el-checkbox-group>
<p class="field-hint w-full mt-8px">{{
apkOnly
? 'APK 整包发布至 Android,上传 WGT 后可选择多个平台。'
: '同一份 WGT 可同时发布至 Android 和 iOS,也可按需选择 HarmonyOS。'
}}</p>
</el-form-item>
<el-form-item v-if="!readOnly" label="上传安装包" prop="packageId">
<el-upload
class="package-upload w-full"
drag
:show-file-list="false"
accept=".apk,.wgt"
:disabled="uploading || saving"
:http-request="handleUpload"
:before-upload="beforeUpload"
>
<Icon icon="ep:upload-filled" class="upload-icon" aria-hidden="true" />
<div class="font-600">{{
uploading
? '正在上传安装包'
: selectedPackage
? '点击或拖入文件,替换更新包'
: '点击或拖入安装包'
}}</div>
<div class="field-hint mt-6px">APK / WGT · 最大 512 MB</div>
</el-upload>
</el-form-item>
<div v-if="uploading" class="upload-progress" role="status" aria-live="polite">
<div class="flex justify-between gap-12px mb-8px"
><span>{{
uploadProgress < 100 ? '正在上传' : '上传完成,正在解析并校验安装包…'
}}</span
><span>{{ uploadProgress }}%</span></div
>
<el-progress :percentage="uploadProgress" :show-text="false" :stroke-width="6" />
</div>
<el-alert
v-if="uploadError"
:title="uploadError"
type="error"
:closable="false"
show-icon
class="mb-16px"
/>
<div v-if="selectedPackage" class="package-card">
<div class="flex items-start justify-between gap-12px mb-16px">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-8px"
><el-tag size="small">{{ selectedPackage.packageType.toUpperCase() }}</el-tag
><strong class="package-file">{{ selectedPackage.fileName }}</strong></div
>
<p class="field-hint mt-8px"
>{{ formatFileSize(selectedPackage.fileSize) }} · {{ selectedPackage.appId }}</p
>
</div>
<el-tag type="success" effect="plain" class="shrink-0">已校验</el-tag>
</div>
<div class="package-metadata">
<div
><span>展示版本</span><strong>{{ selectedPackage.versionName }}</strong></div
>
<div
><span>内部版本号</span><strong>{{ selectedPackage.versionCode }}</strong></div
>
<div
><span>资源版本码</span
><strong>{{ selectedPackage.resourceVersionCode }}</strong></div
>
<div v-if="selectedPackage.packageType === 'apk'"
><span>基座版本码</span
><strong>{{
selectedPackage.nativeVersionCode || selectedPackage.versionCode
}}</strong></div
>
<div v-if="selectedPackage.packageName" class="metadata-wide"
><span>Android 包名</span><strong>{{ selectedPackage.packageName }}</strong></div
>
</div>
<div class="package-actions">
<button type="button" class="text-action" @click="copyPackageUrl"
>复制下载地址</button
>
</div>
<details class="package-checksum"
><summary>查看 SHA-256 校验值</summary
><code>{{ selectedPackage.sha256 }}</code></details
>
</div>
<div v-if="selectedPackage?.packageType === 'wgt'" class="native-range mt-20px">
<el-form-item label="最低基座版本码" prop="minNativeVersionCode">
<el-input-number
v-model="form.minNativeVersionCode"
:min="1"
:max="2147483647"
:precision="0"
:controls="false"
placeholder="例如 113"
class="!w-full"
/>
</el-form-item>
<el-form-item label="最高基座版本码" prop="maxNativeVersionCode">
<el-input-number
v-model="form.maxNativeVersionCode"
:min="form.minNativeVersionCode || 1"
:max="2147483647"
:precision="0"
:controls="false"
placeholder="留空表示不限制"
class="!w-full"
/>
</el-form-item>
<p class="field-hint range-hint"
>填写原生安装包的内部版本码。新增原生模块或权限时,需要先更新基座。</p
>
</div>
</section>
<section class="form-section">
<div class="section-heading"
><div class="section-icon"><Icon icon="ep:document" /></div
><div><h3>更新说明</h3><p>将展示在 App 的更新提示中。</p></div></div
>
<el-form-item label="本次更新内容" prop="updateContent">
<el-input
v-model.trim="form.updateContent"
type="textarea"
:rows="5"
maxlength="4000"
show-word-limit
placeholder="例如&#10;优化聊天消息加载体验&#10;修复已知问题"
/>
</el-form-item>
</section>
<section class="form-section">
<div class="section-heading"
><div class="section-icon"><Icon icon="ep:promotion" /></div
><div><h3>发布设置</h3><p>保存草稿后,可在列表中确认发布。</p></div></div
>
<el-form-item label="更新方式">
<el-radio-group v-model="form.forceUpdate"
><el-radio :value="false">普通更新</el-radio
><el-radio :value="true">强制更新</el-radio></el-radio-group
>
<p class="field-hint w-full">{{
form.forceUpdate ? '目标会员需要完成更新后继续使用 App。' : '会员可选择稍后更新。'
}}</p>
</el-form-item>
<el-form-item label="发布范围">
<el-radio-group v-model="form.releaseScope"
><el-radio value="all">全量用户</el-radio
><el-radio value="members">指定会员灰测</el-radio></el-radio-group
>
</el-form-item>
<div v-if="form.releaseScope === 'members'" class="gray-members-panel">
<div class="flex flex-wrap items-center justify-between gap-12px mb-12px">
<strong
>灰测会员 <span class="member-count">{{ selectedMembers.length }} 人</span></strong
>
<div v-if="!readOnly" class="flex flex-wrap gap-8px">
<el-button :loading="copyingMembers" :disabled="uploading" @click="reuseMembers"
><Icon icon="ep:copy-document" class="mr-6px" />沿用上版名单</el-button
>
<el-button type="primary" plain @click="memberSelectorRef?.open(selectedMembers)"
><Icon icon="ep:user" class="mr-6px" />选择会员</el-button
>
</div>
</div>
<p v-if="copiedFrom" class="field-hint mb-12px" role="status"
>已沿用 {{ copiedFrom }} 的名单,可继续增删会员。</p
>
<p v-if="!selectedMembers.length" class="field-hint py-16px"
>添加会员后,该版本只会向名单内的登录会员推送。</p
>
<div v-else class="member-tags flex flex-wrap gap-8px">
<el-tag
v-for="member in visibleMembers"
:key="member.id"
:closable="!readOnly"
effect="plain"
@close="removeMember(member.id)"
>{{ member.nickname || '会员' }} · {{ member.userCode || member.id }}</el-tag
>
</div>
<button
v-if="selectedMembers.length > 12"
type="button"
class="text-action mt-12px"
:aria-expanded="showAllMembers"
@click="showAllMembers = !showAllMembers"
>
{{ showAllMembers ? '收起名单' : `展开全部 ${selectedMembers.length} 人` }}
</button>
<p v-if="memberError" class="form-inline-error mt-8px" role="alert">{{
memberError
}}</p>
</div>
</section>
</el-form>
</div>
<template #footer>
<div class="drawer-footer">
<span v-if="!readOnly" class="field-hint">版本号与包信息由上传文件读取</span>
<div class="flex gap-10px ml-auto">
<el-button :disabled="saving || uploading" @click="requestClose">{{
readOnly ? '关闭' : '取消'
}}</el-button>
<el-button
v-if="!readOnly"
type="primary"
:loading="saving"
:disabled="loading || uploading || copyingMembers || !!loadError"
@click="save"
>保存{{ detailStatus === 'offline' ? '修改' : '草稿' }}</el-button
>
</div>
</div>
</template>
</el-drawer>
<MemberSelector ref="memberSelectorRef" @confirm="setMembers" />
</template>
<script setup lang="ts">
import type { FormInstance, FormRules, UploadRawFile, UploadRequestOptions } from 'element-plus'
import type { AxiosProgressEvent } from 'axios'
import { useClipboard } from '@vueuse/core'
import * as VersionApi from '@/api/app/version'
import type { GrayMember, VersionPackage, VersionSaveRequest } from '@/api/app/version'
import MemberSelector from './MemberSelector.vue'
/** 发布表单,包内元数据由服务端校验后读取。 */
defineOptions({ name: 'AppVersionForm' })
const emit = defineEmits<{ success: [] }>()
const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const visible = ref(false)
const mode = ref<'create' | 'copy' | 'update' | 'view'>('create')
const loading = ref(false)
const saving = ref(false)
const loadError = ref('')
const copySource = ref('')
const detailStatus = ref<VersionApi.VersionStatus>('draft')
const selectedPackage = ref<VersionPackage>()
const selectedMembers = ref<GrayMember[]>([])
const memberError = ref('')
const copyingMembers = ref(false)
const copiedFrom = ref('')
const showAllMembers = ref(false)
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadError = ref('')
const formRef = ref<FormInstance>()
const memberSelectorRef = ref<InstanceType<typeof MemberSelector>>()
const form = ref<VersionSaveRequest>({
packageId: 0,
platforms: ['android', 'ios'],
updateContent: '',
forceUpdate: false,
releaseScope: 'all',
memberIds: []
})
/** 当前抽屉读取的版本编号与已保存快照,用于失败重试及未保存确认。 */
let versionId: number | undefined
let savedSnapshot = ''
let openSequence = 0
const readOnly = computed(() => mode.value === 'view')
const apkOnly = computed(() => selectedPackage.value?.packageType === 'apk')
const drawerTitle = computed(() =>
mode.value === 'create'
? '新增版本'
: mode.value === 'copy'
? '复制版本'
: readOnly.value
? '版本详情'
: '编辑版本'
)
const visibleMembers = computed(() =>
showAllMembers.value ? selectedMembers.value : selectedMembers.value.slice(0, 12)
)
const rules: FormRules = {
platforms: [
{
type: 'array',
required: true,
min: 1,
message: '请至少选择一个发布目标平台',
trigger: 'change'
}
],
packageId: [
{
validator: (_rule, value, callback) =>
value ? callback() : callback(new Error('请上传并校验安装包')),
trigger: 'change'
}
],
updateContent: [
{ required: true, message: '请填写本次更新内容', trigger: 'blur' },
{ max: 4000, message: '更新内容最多 4000 字', trigger: 'blur' }
],
minNativeVersionCode: [
{
validator: (_rule, value, callback) => {
if (selectedPackage.value?.packageType === 'wgt' && (!Number.isInteger(value) || value < 1))
callback(new Error('请填写最低基座版本码'))
else callback()
},
trigger: 'change'
}
],
maxNativeVersionCode: [
{
validator: (_rule, value, callback) => {
if (
value != null &&
(!Number.isInteger(value) || value < (form.value.minNativeVersionCode || 1))
)
callback(new Error('最高基座版本码应大于或等于最低值'))
else callback()
},
trigger: 'change'
}
]
}
/** 只跟踪可保存字段及灰测名单,排除界面展开等临时状态。 */
const snapshot = () =>
JSON.stringify({
...form.value,
memberIds: selectedMembers.value.map((member) => member.id)
})
/** 把字节数转换为用户可读的安装包大小。 */
const formatFileSize = (size: number) =>
size >= 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(1)} MB` : `${(size / 1024).toFixed(1)} KB`
/** 加载详情时同时恢复会员资料,兼容只返回会员编号的历史数据。 */
const loadVersion = async () => {
if (!versionId) return
const sequence = openSequence
loading.value = true
loadError.value = ''
try {
const data = await VersionApi.getVersion(versionId)
if (sequence !== openSequence) return
detailStatus.value = data.status
if (data.status === 'published' && mode.value !== 'copy') mode.value = 'view'
selectedPackage.value = mode.value === 'copy' ? undefined : { ...data, id: data.packageId }
form.value = {
id: mode.value === 'copy' ? undefined : data.id,
packageId: mode.value === 'copy' ? 0 : data.packageId,
platforms: VersionApi.getTargetPlatforms(data),
updateContent: data.updateContent || '',
forceUpdate: data.forceUpdate,
releaseScope: data.releaseScope,
minNativeVersionCode: data.minNativeVersionCode ?? undefined,
maxNativeVersionCode: data.maxNativeVersionCode ?? undefined,
memberIds: data.memberIds || []
}
if (mode.value === 'copy') {
copySource.value = `${data.versionName} (${data.packageType.toUpperCase()})`
detailStatus.value = 'draft'
}
const membersById = new Map((data.members || []).map((member) => [member.id, member]))
selectedMembers.value = (data.memberIds || data.members?.map((member) => member.id) || []).map(
(id) => membersById.get(id) || { id }
)
savedSnapshot = snapshot()
} catch {
if (sequence === openSequence) loadError.value = '版本详情加载失败,请重试'
} finally {
if (sequence === openSequence) loading.value = false
}
}
/** 新建或打开指定发布记录,每次打开都重置上一份未保存数据。 */
const open = async (nextMode: 'create' | 'copy' | 'update' | 'view', id?: number) => {
openSequence++
mode.value = nextMode
loading.value = false
copyingMembers.value = false
versionId = id
selectedPackage.value = undefined
selectedMembers.value = []
form.value = {
packageId: 0,
platforms: ['android', 'ios'],
updateContent: '',
forceUpdate: false,
releaseScope: 'all',
memberIds: []
}
detailStatus.value = 'draft'
loadError.value = ''
copySource.value = ''
memberError.value = ''
copiedFrom.value = ''
uploadError.value = ''
uploadProgress.value = 0
showAllMembers.value = false
savedSnapshot = snapshot()
visible.value = true
if (id) await loadVersion()
await nextTick()
formRef.value?.clearValidate()
}
/** 上传前限制扩展名与大小,实际包结构和版本由后端二次校验。 */
const beforeUpload = (file: UploadRawFile) => {
const extension = file.name.split('.').pop()?.toLowerCase()
const allowed = ['apk', 'wgt']
if (uploading.value) return false
if (!extension || !allowed.includes(extension)) {
uploadError.value = `请选择 ${allowed.join(' / ').toUpperCase()} 文件`
return false
}
if (!file.size || file.size > 512 * 1024 * 1024) {
uploadError.value = '安装包不能为空,且大小不能超过 512 MB'
return false
}
return true
}
/** 展示传输进度,100% 后仍等待服务端完成结构与校验值解析。 */
const handleUpload = async (options: UploadRequestOptions) => {
uploading.value = true
uploadProgress.value = 0
uploadError.value = ''
try {
const replacingApk = selectedPackage.value?.packageType === 'apk'
const uploaded = await VersionApi.uploadPackage(options.file, (event: AxiosProgressEvent) => {
uploadProgress.value = event.total
? Math.min(100, Math.round((event.loaded / event.total) * 100))
: 0
options.onProgress({ percent: uploadProgress.value } as Parameters<
typeof options.onProgress
>[0])
})
selectedPackage.value = uploaded
form.value.packageId = uploaded.id
if (uploaded.packageType === 'apk') {
form.value.platforms = ['android']
form.value.minNativeVersionCode = undefined
form.value.maxNativeVersionCode = undefined
} else if (replacingApk) {
form.value.platforms = ['android', 'ios']
}
await nextTick()
formRef.value?.clearValidate(['packageId', 'platforms'])
message.success('安装包上传并校验成功')
return uploaded
} catch (error) {
uploadError.value = '安装包上传或校验失败,请确认文件有效后重试'
throw error
} finally {
uploading.value = false
}
}
/** 选择器确认后更新表单的名单,切换全量时仍保留临时名单以便返回编辑。 */
const setMembers = (members: GrayMember[]) => {
selectedMembers.value = members
memberError.value = ''
}
/** 从当前名单移除一位会员。 */
const removeMember = (id: number) => {
selectedMembers.value = selectedMembers.value.filter((member) => member.id !== id)
}
/** 使用最近一份灰测版本的名单,新建及跨平台发布均可沿用。 */
const reuseMembers = async () => {
const sequence = openSequence
copyingMembers.value = true
try {
const data = await VersionApi.getVersionPage({
pageNo: 1,
pageSize: 100,
releaseScope: 'members'
})
if (sequence !== openSequence || !visible.value) return
const source = data.list.find((version) => version.id !== versionId && version.memberCount > 0)
if (!source) {
message.info('还没有可沿用的灰测名单')
return
}
const detail = await VersionApi.getVersion(source.id)
if (sequence !== openSequence || !visible.value) return
if (selectedMembers.value.length) {
try {
await message.confirm(
`使用 ${source.versionName} 的 ${detail.memberIds?.length || detail.members?.length || 0} 人名单替换当前选择?`,
'沿用上版灰测名单'
)
} catch {
return
}
}
if (sequence !== openSequence || !visible.value) return
const membersById = new Map((detail.members || []).map((member) => [member.id, member]))
setMembers(
(detail.memberIds || detail.members?.map((member) => member.id) || []).map(
(id) => membersById.get(id) || { id }
)
)
copiedFrom.value = `${source.versionName} (${source.packageType.toUpperCase()})`
} finally {
if (sequence === openSequence) copyingMembers.value = false
}
}
/** 详情页和编辑页均可复制经过校验的包地址。 */
const copyPackageUrl = async () => {
if (!selectedPackage.value?.downloadUrl) return
try {
await copy(selectedPackage.value.downloadUrl)
message.success('下载地址已复制')
} catch {
message.error('复制失败,请检查浏览器权限后重试')
}
}
/** 保存前统一校验包、基座范围与灰测人群,并阻止重复提交。 */
const save = async () => {
if (saving.value || uploading.value || copyingMembers.value || readOnly.value) return
memberError.value = ''
const valid = await formRef.value?.validate().catch(() => false)
if (form.value.releaseScope === 'members' && !selectedMembers.value.length)
memberError.value = '请至少选择一位灰测会员'
if (!valid || memberError.value) return
saving.value = true
try {
const payload = {
...form.value,
memberIds:
form.value.releaseScope === 'members'
? selectedMembers.value.map((member) => member.id)
: []
}
if (mode.value === 'create' || mode.value === 'copy') await VersionApi.createVersion(payload)
else await VersionApi.updateVersion(payload)
savedSnapshot = snapshot()
visible.value = false
message.success(
mode.value === 'create' || mode.value === 'copy' ? '草稿已保存,可在列表中发布' : '版本已保存'
)
emit('success')
} finally {
saving.value = false
}
}
/** 上传及提交期间保持抽屉稳定,其余未保存编辑离开前确认。 */
const beforeClose = async (done: () => void) => {
if (uploading.value || saving.value) return
if (!readOnly.value && savedSnapshot !== snapshot()) {
try {
await message.confirm('当前修改尚未保存,确认离开?', '放弃未保存修改')
} catch {
return
}
}
openSequence++
done()
}
/** 底部关闭按钮沿用与系统关闭一致的未保存保护。 */
const requestClose = () =>
beforeClose(() => {
visible.value = false
})
defineExpose({ open })
</script>
<style scoped lang="scss">
.version-form-content {
max-width: 780px;
margin: 0 auto;
}
.form-section {
padding: 0 0 24px;
margin-bottom: 24px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.form-section:last-child {
border-bottom: 0;
margin-bottom: 0;
}
.section-heading {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.section-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 38px;
height: 38px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.section-heading h3 {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 24px;
}
.section-heading p,
.field-hint {
font-size: 12px;
line-height: 20px;
color: var(--el-text-color-regular);
}
:deep(.el-form-item__label) {
font-weight: 500;
color: var(--el-text-color-primary);
}
:deep(.el-upload-dragger) {
padding: 24px 16px;
background: var(--el-fill-color-extra-light);
border-radius: 12px;
}
:deep(.el-upload) {
width: 100%;
}
:deep(.el-input-number .el-input__inner) {
text-align: left;
}
.upload-icon {
font-size: 32px;
color: var(--el-color-primary);
margin-bottom: 10px;
}
.upload-progress {
font-size: 13px;
padding: 0 0 20px;
color: var(--el-text-color-regular);
}
.package-card,
.gray-members-panel {
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
padding: 18px;
background: var(--el-fill-color-extra-light);
}
.package-file {
word-break: break-all;
}
.package-metadata {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px 20px;
}
.package-metadata > div {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.package-metadata span {
font-size: 12px;
color: var(--el-text-color-regular);
}
.package-metadata strong {
font-size: 14px;
font-weight: 500;
overflow-wrap: anywhere;
color: var(--el-text-color-primary);
}
.metadata-wide {
grid-column: 1 / -1;
}
.package-actions {
margin-top: 14px;
}
.text-action {
padding: 0;
border: 0;
font: inherit;
font-size: 12px;
line-height: 22px;
color: var(--el-color-primary);
background: transparent;
cursor: pointer;
}
.text-action:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
border-radius: 2px;
}
.member-tags {
max-height: 240px;
overflow: auto;
}
.package-checksum {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--el-border-color-lighter);
font-size: 12px;
color: var(--el-text-color-regular);
}
.package-checksum summary {
cursor: pointer;
width: fit-content;
}
.package-checksum code {
display: block;
margin-top: 8px;
overflow-wrap: anywhere;
}
.target-platform-options {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.target-platform-options :deep(.el-checkbox.is-bordered) {
margin: 0;
}
.native-range {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.range-hint {
grid-column: 1 / -1;
margin-top: -6px;
}
.member-count {
margin-left: 8px;
color: var(--el-color-primary);
font-weight: 500;
}
.form-inline-error {
color: var(--el-color-danger);
font-size: 12px;
}
.drawer-footer {
display: flex;
align-items: center;
gap: 16px;
}
@media (max-width: 540px) {
.native-range {
grid-template-columns: 1fr;
}
.drawer-footer > .field-hint {
display: none;
}
.gray-members-panel {
padding: 14px;
}
}
</style>
+642
View File
@@ -0,0 +1,642 @@
<template>
<div class="version-page">
<header class="version-header">
<div class="min-w-0"><h1>版本管理</h1><p>管理 App 安装包、热更新与会员灰测发布。</p></div>
<el-button
v-hasPermi="['app:version:create']"
type="primary"
size="large"
@click="formRef?.open('create')"
>
<Icon icon="ep:plus" class="mr-6px" />新增版本
</el-button>
</header>
<ContentWrap class="version-list-wrap">
<div class="platform-toolbar">
<el-radio-group v-model="platformFilter" aria-label="筛选目标平台" @change="changePlatform">
<el-radio-button value="">全部平台</el-radio-button>
<el-radio-button value="android">Android</el-radio-button>
<el-radio-button value="ios">iOS</el-radio-button>
<el-radio-button value="harmony">HarmonyOS</el-radio-button>
</el-radio-group>
<el-button text :loading="loading" aria-label="刷新版本列表" @click="getList"
><Icon icon="ep:refresh" class="mr-6px" />刷新</el-button
>
</div>
<el-form
class="version-filters"
:model="query"
:inline="true"
label-position="top"
@submit.prevent="search"
>
<el-form-item label="搜索版本">
<el-input
v-model.trim="query.keyword"
clearable
placeholder="版本号、更新内容"
aria-label="搜索版本号或更新内容"
@clear="search"
/>
</el-form-item>
<el-form-item label="更新包类型">
<el-select
v-model="query.packageType"
clearable
placeholder="全部类型"
aria-label="筛选更新包类型"
>
<el-option label="APK 整包" value="apk" /><el-option label="WGT 热更新" value="wgt" />
</el-select>
</el-form-item>
<el-form-item label="发布范围">
<el-select
v-model="query.releaseScope"
clearable
placeholder="全部范围"
aria-label="筛选发布范围"
>
<el-option label="全量用户" value="all" /><el-option
label="指定会员灰测"
value="members"
/>
</el-select>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" plain native-type="submit"
><Icon icon="ep:search" class="mr-6px" />搜索</el-button
>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<el-tabs v-model="statusFilter" class="version-status-tabs" @tab-change="changeStatus">
<el-tab-pane label="全部版本" name="" /><el-tab-pane
label="已发布"
name="published"
/><el-tab-pane label="草稿" name="draft" /><el-tab-pane label="已下架" name="offline" />
</el-tabs>
<el-alert
v-if="listError"
:title="listError"
type="error"
:closable="false"
show-icon
class="mb-16px"
>
<el-button link type="primary" @click="getList">重新加载</el-button>
</el-alert>
<el-table
v-loading="loading"
:data="list"
row-key="id"
class="version-table"
:show-overflow-tooltip="false"
>
<el-table-column label="版本" min-width="170">
<template #default="{ row }">
<button class="version-name" type="button" @click="formRef?.open('view', row.id)">{{
row.versionName
}}</button>
<div class="cell-secondary">版本码 {{ row.versionCode }}</div>
</template>
</el-table-column>
<el-table-column label="目标平台 / 更新包" min-width="180">
<template #default="{ row }"
><div class="target-platforms"
><span v-for="target in VersionApi.getTargetPlatforms(row)" :key="target">{{
platformNames[target]
}}</span></div
><el-tag
class="mt-6px"
size="small"
effect="plain"
:type="row.packageType === 'apk' ? 'info' : 'primary'"
>{{ row.packageType === 'apk' ? 'APK 整包' : 'WGT 热更新' }}</el-tag
></template
>
</el-table-column>
<el-table-column label="更新内容" min-width="230">
<template #default="{ row }"
><el-tooltip :content="row.updateContent" placement="top" :show-after="500"
><div class="update-content">{{ row.updateContent || '—' }}</div></el-tooltip
></template
>
</el-table-column>
<el-table-column label="发布范围" min-width="140">
<template #default="{ row }">
<el-button
v-if="row.releaseScope === 'members'"
link
type="primary"
@click="formRef?.open('view', row.id)"
><Icon icon="ep:user" class="mr-5px" />灰测 {{ row.memberCount || 0 }} 人</el-button
>
<span v-else>全量用户</span>
<div class="cell-secondary">{{ row.forceUpdate ? '强制更新' : '普通更新' }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="104">
<template #default="{ row }"
><el-tag :type="statusTypes[row.status]" effect="light">{{
statusNames[row.status] || row.status
}}</el-tag></template
>
</el-table-column>
<el-table-column label="发布时间" min-width="170">
<template #default="{ row }"
><span>{{ row.publishTime ? formatDate(row.publishTime) : '待发布' }}</span
><div class="cell-secondary"
>创建 {{ formatDate(row.createTime) || '—' }}</div
></template
>
</el-table-column>
<el-table-column label="操作" :fixed="wideScreen ? 'right' : false" width="250">
<template #default="{ row }">
<div class="version-actions">
<el-button
v-if="row.status !== 'published' && checkPermi(['app:version:update'])"
link
type="primary"
:disabled="isBusy(row.id)"
@click="formRef?.open('update', row.id)"
>编辑</el-button
>
<el-button
v-if="row.status !== 'published' && checkPermi(['app:version:publish'])"
link
type="primary"
:disabled="isBusy(row.id)"
@click="publish(row)"
>发布</el-button
>
<el-button
v-if="
row.status === 'published' &&
row.releaseScope === 'members' &&
checkPermi(['app:version:publish'])
"
link
type="primary"
:disabled="isBusy(row.id)"
@click="promote(row)"
>转全量</el-button
>
<el-button
v-if="row.status === 'published' && checkPermi(['app:version:publish'])"
link
type="warning"
:disabled="isBusy(row.id)"
@click="offline(row)"
>下架</el-button
>
<el-dropdown
:disabled="isBusy(row.id)"
@command="(command) => handleCommand(command, row)"
>
<el-button
link
type="primary"
:loading="isBusy(row.id)"
:aria-label="`版本${row.versionName}的更多操作`"
>更多<Icon icon="ep:arrow-down" class="ml-4px"
/></el-button>
<template #dropdown
><el-dropdown-menu>
<el-dropdown-item command="view">查看详情</el-dropdown-item>
<el-dropdown-item
v-if="checkPermi(['app:version:create'])"
command="copy-version"
>复制为新版本</el-dropdown-item
>
<el-dropdown-item command="copy" :disabled="!row.downloadUrl"
>复制下载地址</el-dropdown-item
>
<el-dropdown-item command="download" :disabled="!row.downloadUrl"
>下载安装包</el-dropdown-item
>
<el-dropdown-item
v-if="row.status !== 'published' && checkPermi(['app:version:delete'])"
command="delete"
divided
><span class="delete-action">删除版本</span></el-dropdown-item
>
</el-dropdown-menu></template
>
</el-dropdown>
</div>
</template>
</el-table-column>
<template #empty>
<div class="py-36px"
><el-empty
:description="
listError
? '版本列表暂时无法加载'
: hasFilters
? '没有匹配的版本'
: '还没有版本,上传更新包开始发布'
"
:image-size="96"
/>
<el-button
v-if="!hasFilters && !listError"
v-hasPermi="['app:version:create']"
type="primary"
plain
@click="formRef?.open('create')"
>新增版本</el-button
>
</div>
</template>
</el-table>
<Pagination
v-model:page="query.pageNo"
v-model:limit="query.pageSize"
:total="total"
@pagination="getList"
/>
</ContentWrap>
<VersionForm ref="formRef" @success="getList" />
</div>
</template>
<script setup lang="ts">
import { useClipboard, useMediaQuery } from '@vueuse/core'
import { checkPermi } from '@/utils/permission'
import { formatDate } from '@/utils/formatTime'
import * as VersionApi from '@/api/app/version'
import type { AppPlatform, AppVersion, VersionPageRequest, VersionStatus } from '@/api/app/version'
import VersionForm from './VersionForm.vue'
/** App 版本发布工作台,灰测和全量发布共用同一份经过校验的安装包。 */
defineOptions({ name: 'AppVersion' })
const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const wideScreen = useMediaQuery('(min-width: 768px)')
const formRef = ref<InstanceType<typeof VersionForm>>()
const loading = ref(false)
const listError = ref('')
const list = ref<AppVersion[]>([])
const total = ref(0)
const busyIds = ref<number[]>([])
const platformFilter = ref<AppPlatform | ''>('')
const statusFilter = ref<VersionStatus | ''>('')
const query = reactive<VersionPageRequest>({ pageNo: 1, pageSize: 10, keyword: '' })
const platformNames: Record<AppPlatform, string> = {
android: 'Android',
ios: 'iOS',
harmony: 'HarmonyOS'
}
const statusNames: Record<VersionStatus, string> = {
draft: '草稿',
published: '已发布',
offline: '已下架'
}
const statusTypes: Record<VersionStatus, 'info' | 'success' | 'warning'> = {
draft: 'info',
published: 'success',
offline: 'warning'
}
const hasFilters = computed(
() =>
!!(
query.keyword ||
query.packageType ||
query.releaseScope ||
platformFilter.value ||
statusFilter.value
)
)
/** 快速切换条件时只接受最后一轮查询结果。 */
let requestSequence = 0
/** 分页查询当前条件下的版本,失败保留错误反馈以便重试。 */
const getList = async () => {
const sequence = ++requestSequence
loading.value = true
listError.value = ''
try {
const data = await VersionApi.getVersionPage({
...query,
keyword: query.keyword || undefined,
platform: platformFilter.value || undefined,
status: statusFilter.value || undefined
})
if (sequence !== requestSequence) return
list.value = data.list
total.value = data.total
} catch {
if (sequence === requestSequence) {
listError.value = '版本列表加载失败,请重试'
list.value = []
}
} finally {
if (sequence === requestSequence) loading.value = false
}
}
/** 新的检索条件从第一页开始。 */
const search = () => {
query.pageNo = 1
getList()
}
/** 切换平台后保留其他筛选条件。 */
const changePlatform = () => search()
/** 切换发布状态后保留其他筛选条件。 */
const changeStatus = () => search()
/** 还原全部筛选条件。 */
const reset = () => {
query.keyword = ''
query.packageType = undefined
query.releaseScope = undefined
platformFilter.value = ''
statusFilter.value = ''
search()
}
/** 检查同一行是否已有发布操作正在进行。 */
const isBusy = (id: number) => busyIds.value.includes(id)
/** 确认目标和影响后执行单次状态操作,成功重新读取服务端状态。 */
const runAction = async (
row: AppVersion,
title: string,
content: string,
action: (id: number) => Promise<unknown>,
success: string
) => {
if (isBusy(row.id)) return
try {
await message.confirm(content, title)
} catch {
return
}
if (isBusy(row.id)) return
busyIds.value.push(row.id)
try {
await action(row.id)
message.success(success)
if (title === '删除版本' && list.value.length === 1 && query.pageNo > 1) query.pageNo--
await getList()
} finally {
busyIds.value = busyIds.value.filter((id) => id !== row.id)
}
}
/** 状态确认覆盖全部发布目标,兼容历史单平台记录。 */
const targetPlatformLabel = (version: AppVersion) =>
VersionApi.getTargetPlatforms(version)
.map((platform) => platformNames[platform])
.join(' / ')
/** 发布前明确展示平台、包、范围和强制策略。 */
const publish = (row: AppVersion) => {
const audience =
row.releaseScope === 'members' ? `${row.memberCount || 0} 位指定会员` : '全量用户'
if (row.releaseScope === 'members' && !row.memberCount) {
message.warning('请先编辑版本并添加灰测会员')
return
}
return runAction(
row,
'确认发布版本',
`将 ${targetPlatformLabel(row)} ${row.versionName} (${row.packageType.toUpperCase()}) 发布给${audience},采用${row.forceUpdate ? '强制更新' : '普通更新'}。确认发布?`,
VersionApi.publishVersion,
'版本已发布'
)
}
/** 将正在灰测的同一份安装包开放给全部用户。 */
const promote = (row: AppVersion) =>
runAction(
row,
'转为全量发布',
`将 ${targetPlatformLabel(row)} ${row.versionName} 从会员灰测转为全量发布,${row.forceUpdate ? '全量用户需要强制更新' : '全量用户将收到更新提示'}。确认继续?`,
VersionApi.promoteVersion,
'已转为全量发布'
)
/** 下架后客户端停止获取该版本。 */
const offline = (row: AppVersion) =>
runAction(
row,
'下架版本',
`确认下架 ${targetPlatformLabel(row)} ${row.versionName}?下架后停止向客户端推送该版本。`,
VersionApi.offlineVersion,
'版本已下架'
)
/** 删除记录前明确提示不可恢复。 */
const remove = (row: AppVersion) =>
runAction(
row,
'删除版本',
`确认删除版本 ${row.versionName}?删除后无法恢复该发布记录。`,
VersionApi.deleteVersion,
'版本已删除'
)
/** 处理只读详情、下载链接与删除等次级操作。 */
const handleCommand = async (command: string, row: AppVersion) => {
if (command === 'view') {
formRef.value?.open('view', row.id)
return
}
if (command === 'copy-version') {
formRef.value?.open('copy', row.id)
return
}
if (command === 'delete') {
await remove(row)
return
}
if (command === 'copy') {
try {
await copy(row.downloadUrl)
message.success('下载地址已复制')
} catch {
message.error('复制失败,请在版本详情中重试')
}
return
}
if (command === 'download') {
try {
const url = new URL(row.downloadUrl, window.location.origin)
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('下载地址无效')
window.open(url.href, '_blank', 'noopener,noreferrer')
} catch {
message.error('安装包下载地址无效')
}
}
}
onMounted(getList)
onBeforeUnmount(() => {
requestSequence++
})
</script>
<style scoped lang="scss">
.version-page {
color: var(--el-text-color-primary);
}
.version-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
margin-bottom: 24px;
}
.version-header h1 {
margin: 0;
font-size: 24px;
line-height: 34px;
font-weight: 600;
letter-spacing: -0.5px;
}
.version-header p {
margin-top: 6px;
font-size: 14px;
line-height: 22px;
color: var(--el-text-color-regular);
}
.platform-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-bottom: 20px;
}
.version-filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0 16px;
padding: 18px 18px 0;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
}
.version-filters :deep(.el-form-item) {
margin-right: 0;
margin-bottom: 18px;
}
.version-filters :deep(.el-form-item__label) {
color: var(--el-text-color-regular);
font-size: 12px;
padding-bottom: 6px;
line-height: 18px;
}
.version-filters :deep(.el-input) {
width: 240px;
}
.version-filters :deep(.el-select) {
width: 150px;
}
.filter-actions {
margin-left: auto;
}
.version-status-tabs {
margin-top: 20px;
}
.version-status-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.version-status-tabs :deep(.el-tabs__nav-wrap::after) {
height: 1px;
}
.version-table :deep(.el-table__cell) {
padding-top: 16px;
padding-bottom: 16px;
}
.version-table :deep(.el-table__header .cell) {
color: var(--el-text-color-regular);
font-weight: 500;
}
.version-name {
padding: 0;
border: 0;
font: inherit;
color: var(--el-text-color-primary);
background: transparent;
cursor: pointer;
font-weight: 600;
font-size: 15px;
line-height: 24px;
}
.version-name:hover {
color: var(--el-color-primary);
}
.version-name:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 3px;
border-radius: 2px;
}
.cell-secondary {
margin-top: 4px;
color: var(--el-text-color-regular);
font-size: 12px;
line-height: 20px;
}
.update-content {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 22px;
white-space: pre-line;
color: var(--el-text-color-regular);
}
.target-platforms {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
}
.target-platforms > span {
white-space: nowrap;
}
.version-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.version-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.delete-action {
color: var(--el-color-danger);
}
@media (max-width: 640px) {
.version-header {
align-items: flex-start;
gap: 12px;
}
.version-header h1 {
font-size: 21px;
}
.version-header p {
max-width: 210px;
}
.version-filters {
gap: 0 10px;
padding: 14px 12px 0;
}
.version-filters :deep(.el-form-item) {
width: 100%;
}
.version-filters :deep(.el-form-item__content),
.version-filters :deep(.el-input),
.version-filters :deep(.el-select) {
width: 100%;
}
.filter-actions {
margin-left: 0;
}
.platform-toolbar :deep(.el-radio-button__inner) {
padding: 8px 10px;
}
}
</style>
@@ -1,61 +1,33 @@
<template>
<ContentWrap>
<el-form
:model="queryParams"
:inline="true"
class="-mb-15px"
label-width="84px"
@submit.prevent
>
<el-form-item v-if="fixedUserId === undefined" label="收益人编号">
<el-input
v-model="queryParams.userId"
placeholder="请输入会员编号"
clearable
class="!w-200px"
@keyup.enter="handleQuery"
/>
<el-form :model="queryParams" :inline="true" class="-mb-15px" label-width="84px" @submit.prevent>
<el-form-item v-if="fixedUserId === undefined" label="用户名">
<el-input v-model="queryParams.userNickname" placeholder="请输入用户名" clearable class="!w-200px"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item v-if="fixedSourceUserId === undefined" label="来源会员">
<el-input
v-model="queryParams.sourceUserId"
placeholder="请输入会员编号"
clearable
class="!w-200px"
@keyup.enter="handleQuery"
/>
<el-input v-model="queryParams.sourceUserId" placeholder="请输入会员编号" clearable class="!w-200px"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="queryParams.status"
placeholder="全部状态"
clearable
class="!w-160px"
:value-on-clear="undefined"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_RECORD_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
<el-select v-model="queryParams.status" placeholder="全部状态" clearable class="!w-160px"
:value-on-clear="undefined">
<el-option v-for="dict in getIntDictOptions(DICT_TYPE.BROKERAGE_RECORD_STATUS)" :key="dict.value"
:label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="queryParams.createTime"
:value-on-clear="() => undefined"
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-on-clear="() => undefined"
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-form-item>
</el-form>
</ContentWrap>
@@ -64,38 +36,25 @@
<el-alert :title="error" type="error" :closable="false" show-icon />
<el-button @click="getList">重试</el-button>
</div>
<el-table
v-else
v-loading="loading"
:data="list"
stripe
:show-overflow-tooltip="true"
scrollbar-always-on
>
<el-table v-else v-loading="loading" :data="list" stripe :show-overflow-tooltip="true" scrollbar-always-on>
<el-table-column label="编号" align="center" prop="id" min-width="76" />
<el-table-column label="收益人" align="left" min-width="180">
<template #default="{ row }">
<div class="brokerage-record-list__member">
<el-avatar :src="row.userAvatar || undefined" :size="32" aria-hidden="true"
><Icon icon="ep:user"
/></el-avatar>
<div
><span>{{ row.userNickname || '会员资料缺失' }}</span
><small>编号 {{ row.userId }}</small></div
>
<el-avatar :src="row.userAvatar || undefined" :size="32" aria-hidden="true">
<Icon icon="ep:user" />
</el-avatar>
<div><span>{{ row.userNickname || '会员资料缺失' }}</span><small>编号 {{ row.userId }}</small></div>
</div>
</template>
</el-table-column>
<el-table-column label="来源会员" align="left" min-width="180">
<template #default="{ row }">
<div v-if="row.sourceUserId" class="brokerage-record-list__member">
<el-avatar :src="row.sourceUserAvatar || undefined" :size="32" aria-hidden="true"
><Icon icon="ep:user"
/></el-avatar>
<div
><span>{{ row.sourceUserNickname || '会员资料缺失' }}</span
><small>编号 {{ row.sourceUserId }}</small></div
>
<el-avatar :src="row.sourceUserAvatar || undefined" :size="32" aria-hidden="true">
<Icon icon="ep:user" />
</el-avatar>
<div><span>{{ row.sourceUserNickname || '会员资料缺失' }}</span><small>编号 {{ row.sourceUserId }}</small></div>
</div>
<span v-else>来源未知</span>
</template>
@@ -112,32 +71,14 @@
</el-table-column>
<el-table-column label="说明" align="left" prop="description" min-width="180" />
<el-table-column label="状态" align="center" prop="status" min-width="92">
<template #default="{ row }"
><dict-tag :type="DICT_TYPE.BROKERAGE_RECORD_STATUS" :value="row.status"
/></template>
<template #default="{ row }"><dict-tag :type="DICT_TYPE.BROKERAGE_RECORD_STATUS"
:value="row.status" /></template>
</el-table-column>
<el-table-column
label="解冻时间"
align="center"
prop="unfreezeTime"
:formatter="dateFormatter"
width="180"
/>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180"
/>
<el-table-column label="解冻时间" align="center" prop="unfreezeTime" :formatter="dateFormatter" width="180" />
<el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180" />
</el-table>
<Pagination
v-if="!error"
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
<Pagination v-if="!error" :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>
</template>
@@ -164,7 +105,7 @@ const list = ref<BrokerageRecordVO[]>([])
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;
@@ -10,10 +10,10 @@
:inline="true"
label-width="68px"
>
<el-form-item label="用户编号" prop="userId">
<el-form-item label="用户名" prop="userNickname">
<el-input
v-model="queryParams.userId"
placeholder="请输入用户编号"
v-model.trim="queryParams.userNickname"
placeholder="请输入用户名"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
@@ -175,7 +175,7 @@ const list = ref([])
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
userId: null,
userNickname: '',
type: null,
name: null,
accountNo: null,
@@ -7,6 +7,11 @@
</div>
<div class="brokerage-tree__tools">
<span class="brokerage-tree__legend"><i aria-hidden="true"></i>当前会员</span>
<el-switch
v-model="hideNoIncome"
active-text="隐藏无收益下级"
aria-label="隐藏无收益下级"
/>
<el-button :loading="refreshing" size="small" @click="refresh">
<el-icon class="mr-4px" aria-hidden="true"><RefreshIcon /></el-icon>刷新
</el-button>
@@ -134,7 +139,7 @@
type="primary"
size="small"
:loading="data.branch.loading"
@click="loadChildren(data.member.id)"
@click="retryChildren(data.member.id)"
>重试</el-button
>
<el-button
@@ -211,7 +216,10 @@
</div>
<el-empty v-else description="会员资料加载后可查看分销关系" />
<p v-if="root" class="brokerage-tree__hint"
>滚轮缩放,拖动画布移动。每个分支每次加载 20 人,悬停时钟查看绑定时间。</p
>默认加载并展开两级下级,更深层级可继续展开。滚轮缩放,拖动画布移动,悬停时钟查看绑定时间。<template
v-if="hideNoIncome"
>按为当前会员贡献的已结算收益筛选,保留有收益下级的关系路径及尚未加载完整的分支。</template
></p
>
<UserBrokerageRecordDrawer ref="recordDrawerRef" />
</section>
@@ -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' }
)
</script>
@@ -379,6 +396,7 @@ watch(
}
&__tools {
gap: 16px;
flex-wrap: wrap;
}
&__legend {
gap: 6px;
@@ -25,6 +25,7 @@ export function useBrokerageTree(user: Ref<UserVO | null | undefined>) {
const branches = reactive<Record<number, BrokerageBranch>>({})
const refreshing = ref(false)
const refreshError = ref('')
const hideNoIncome = ref(false)
const parents = new Map<number, number>()
let generation = 0
let refreshVersion = 0
@@ -90,6 +91,42 @@ export function useBrokerageTree(user: Ref<UserVO | null | undefined>) {
}
}
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<UserVO | null | undefined>) {
} 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<UserVO | null | undefined>) {
const edges: { source: number; target: number; bindUserTime?: string | number | null }[] = []
const current = root.value
if (!current) return { nodes, edges }
const retained = new Set<number>()
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<UserVO | null | undefined>) {
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<UserVO | null | undefined>) {
branches,
refreshing,
refreshError,
hideNoIncome,
hasMore,
loadChildren,
retryChildren,
toggleBranch,
refresh
}
+25 -1
View File
@@ -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()
+160 -15
View File
@@ -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()
})