11
This commit is contained in:
1 parent
25b7f38df8
commit
f744e2b9b8
16 files changed
+3425
-126
No files matched your search
@@ -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 })
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]
|
||||
@@ -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>
|
||||
@@ -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="例如 优化聊天消息加载体验 修复已知问题"
|
||||
/>
|
||||
</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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user