feat:增加伪装方便ios上架

This commit is contained in:
dzc committed 2026-11-12 14:03:26 +08:00
1 parent f744e2b9b8
commit 40e6880f77
12 files changed
+2988 -141

No files matched your search

+130
View File
@@ -0,0 +1,130 @@
import request from '@/config/axios'
export type AppMode = 'chat' | 'tea'
export type BookingStatus =
'pending' | 'confirmed' | 'completed' | 'cancelled' | 'rejected' | 'expired'
export interface TeaSlot {
startTime: string
endTime: string
}
export interface AppSetting {
id?: number
iosMode: AppMode
androidMode: AppMode
h5Mode: AppMode
storeName: string
storeCover: string
storeDescription: string
storeAddress: string
storePhone: string
openTime: string
closeTime: string
bookingNotice: string
slots: TeaSlot[]
}
export interface TeaRoom {
id?: number
name: string
cover: string
capacity: number
facilities: string[]
description: string
price: number
sort: number
enabled: boolean
}
export interface TeaBooking {
id: number
bookingNo: string
roomId: number
roomName: string
roomCover: string
storeName: string
storeAddress: string
storePhone: string
date: string
startTime: string
endTime: string
persons: number
contactName: string
contactPhone: string
remark: string
price: number
status: BookingStatus
reason?: string
createTime?: string | number
canCancel: boolean
canConfirm: boolean
canReject: boolean
canComplete: boolean
canAdminCancel: boolean
}
export interface BookingQuery {
pageNo: number
pageSize: number
status?: BookingStatus
roomId?: number
date?: string
keyword?: string
}
export type ClosureStatus = 'pending' | 'processing' | 'resolved' | 'rejected'
export interface AccountClosure {
id: number
memberId: number
nickname: string
contactPhone: string
reason: string
status: ClosureStatus
processRemark: string
createTime: string | number
updateTime: string | number
}
export const getClosurePage = (params: {
pageNo: number
pageSize: number
status?: ClosureStatus
keyword?: string
}) =>
request.get<{ list: AccountClosure[]; total: number }>({
url: '/member/account-closure/page',
params
})
export const updateClosureStatus = (data: {
id: number
status: Exclude<ClosureStatus, 'pending'>
processRemark: string
}) => request.put<boolean>({ url: '/member/account-closure/update-status', data })
export const getSetting = () => request.get<AppSetting | null>({ url: '/member/app-setting/get' })
export const saveSetting = (data: AppSetting) =>
request.put<boolean>({ url: '/member/app-setting/save', data })
export const getRooms = () => request.get<TeaRoom[]>({ url: '/member/tea-room/list' })
export const getRoom = (id: number) =>
request.get<TeaRoom>({ url: '/member/tea-room/get', params: { id } })
export const createRoom = (data: TeaRoom) =>
request.post<number>({ url: '/member/tea-room/create', data })
export const updateRoom = (data: TeaRoom) =>
request.put<boolean>({ url: '/member/tea-room/update', data })
export const deleteRoom = (id: number) =>
request.delete<boolean>({ url: '/member/tea-room/delete', params: { id } })
export const getBookingPage = (params: BookingQuery) =>
request.get<{ list: TeaBooking[]; total: number }>({ url: '/member/tea-booking/page', params })
export const getBooking = (id: number) =>
request.get<TeaBooking>({ url: '/member/tea-booking/get', params: { id } })
export const updateBookingStatus = (data: { id: number; status: BookingStatus; reason?: string }) =>
request.put<boolean>({ url: '/member/tea-booking/update-status', data })
/** Convert user-entered yuan without rounding binary floating-point values. */
export const yuanToFen = (value: string): number | null => {
const match = /^(\d{1,8})(?:\.(\d{1,2}))?$/.exec(value.trim())
if (!match) return null
const amount = Number(match[1]) * 100 + Number((match[2] || '').padEnd(2, '0'))
return Number.isSafeInteger(amount) && amount <= 2147483647 ? amount : null
}
export const fenToYuan = (value: number | string): string => {
const fen = String(value)
if (!/^\d+$/.test(fen)) return '—'
const digits = fen.replace(/^0+(?=\d)/, '').padStart(3, '0')
return `${digits.slice(0, -2)}.${digits.slice(-2)}`
}
@@ -42,3 +42,57 @@ export const clearBindUser = async (data: any) => {
export const updateBrokerageEnabled = async (data: any) => {
return await request.put({ url: `/trade/brokerage-user/update-brokerage-enable`, data })
}
export interface BindMember {
id: number
userCode?: string | null
nickname?: string | null
avatar?: string | null
status?: number
}
export interface BindContext {
user: BindMember
bindUser?: BindMember | null
bindUserId?: number | null
bindUserTime?: string | number | null
version: number | string
bindMode?: number | null
}
export interface BindChangeRequest {
id: number
bindUserId?: number
expectedBindUserId: number | null
expectedVersion: number | string
reason: string
requestId: string
}
export interface BindLog {
oldMember?: BindMember | null
newMember?: BindMember | null
id: number
userId: number
oldBindUserId?: number | null
newBindUserId?: number | null
oldVersion: number | string
newVersion: number | string
operatorId?: number | null
source: string
reason: string
createTime: string | number
}
export const getBindContext = (id: number): Promise<BindContext> =>
request.get({ url: '/trade/brokerage-user/bind-context', params: { id } })
export const getBindLogPage = (params: { id: number; pageNo: number; pageSize: number }) =>
request.get({ url: '/trade/brokerage-user/bind-log-page', params })
export const getBindCandidatePage = (params: {
searchField: 'userCode' | 'nickname' | 'mobile'
keyword: string
pageNo: number
pageSize: number
}) => request.get({ url: '/trade/brokerage-user/bind-candidate-page', params })
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,90 @@
<template>
<el-drawer v-model="visible" title="分销上级变更记录" size="900px" @closed="invalidate">
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="mb-16px"
/>
<el-table v-loading="loading" :data="rows">
<el-table-column label="操作时间" width="165">
<template #default="{ row }">{{ formatDate(new Date(row.createTime)) }}</template>
</el-table-column>
<el-table-column label="原上级" min-width="150">
<template #default="{ row }">{{ identity(row.oldMember, row.oldBindUserId) }}</template>
</el-table-column>
<el-table-column label="新上级" min-width="150">
<template #default="{ row }">{{ identity(row.newMember, row.newBindUserId) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="115">
<template #default="{ row }"
>{{ row.source === 'MEMBER' ? '会员' : '管理员' }}
{{ row.operatorId ?? '系统' }}</template
>
</el-table-column>
<el-table-column label="操作原因" prop="reason" min-width="180" show-overflow-tooltip />
</el-table>
<Pagination
v-if="total > 0"
:total="total"
v-model:page="pageNo"
v-model:limit="pageSize"
@pagination="load"
/>
<el-button class="mt-16px" :loading="loading" @click="load">刷新</el-button>
</el-drawer>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getBindLogPage, type BindLog, type BindMember } from '@/api/mall/trade/brokerage/user'
import { formatDate } from '@/utils/formatTime'
const visible = ref(false)
const loading = ref(false)
const error = ref('')
const rows = ref<BindLog[]>([])
const total = ref(0)
const pageNo = ref(1)
const pageSize = ref(20)
let userId = 0
let generation = 0
async function open(id: number) {
invalidate()
userId = id
pageNo.value = 1
visible.value = true
rows.value = []
total.value = 0
await load()
}
async function load() {
const current = ++generation
loading.value = true
error.value = ''
try {
const result = await getBindLogPage({
id: userId,
pageNo: pageNo.value,
pageSize: pageSize.value
})
if (current !== generation || !visible.value) return
rows.value = result.list
total.value = result.total
} catch {
if (current === generation) error.value = '变更记录加载失败,请重试'
} finally {
if (current === generation) loading.value = false
}
}
function invalidate() {
generation += 1
}
function identity(member?: BindMember | null, id?: number | null) {
if (!id) return '无上级'
return member
? `${member.nickname || '未设置昵称'}(App ID ${member.userCode || '未设置'})`
: `资料缺失(内部编号 ${id})`
}
defineExpose({ open })
</script>
@@ -1,127 +1,223 @@
<template>
<Dialog v-model="dialogVisible" title="修改上级推广人" width="500">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="80px"
>
<el-form-item label="推广人" prop="bindUserId">
<el-input
v-model="formData.bindUserId"
placeholder="请输入推广员编号"
v-loading="formLoading"
>
<template #append>
<el-button @click="handleGetUser"><Icon icon="ep:search" class="mr-5px" /></el-button>
</template>
</el-input>
</el-form-item>
</el-form>
<!-- 展示上级推广人的信息 -->
<el-descriptions v-if="bindUser" :column="1" border>
<el-descriptions-item label="头像">
<el-avatar :src="bindUser.avatar" />
</el-descriptions-item>
<el-descriptions-item label="昵称">{{ bindUser.nickname }}</el-descriptions-item>
<el-descriptions-item label="推广资格">
<el-tag v-if="bindUser.brokerageEnabled">有</el-tag>
<el-tag v-else type="info">无</el-tag>
</el-descriptions-item>
<el-descriptions-item label="成为推广员的时间">
{{ formatDate(bindUser.brokerageTime) }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
<el-button @click="dialogVisible = false">取 消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as BrokerageUserApi from '@/api/mall/trade/brokerage/user'
import { formatDate } from '@/utils/formatTime'
/** 修改分销用户 */
defineOptions({ name: 'BrokerageUserUpdateForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
const formData = ref()
const formRef = ref() // 表单 Ref
const formRules = reactive({
bindUserId: [{ required: true, message: '推广员人不能为空', trigger: 'blur' }]
})
/** 打开弹窗 */
const open = async (row: BrokerageUserApi.BrokerageUserVO) => {
resetForm()
// 设置数据
formData.value.id = row.id
formData.value.bindUserId = row.bindUserId
// 反显上级推广人
if (row.bindUserId) {
await handleGetUser()
}
dialogVisible.value = true
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
/** 修改上级推广人 */
const submitForm = async () => {
if (formLoading.value) return
// 校验表单
if (!formRef) return
const valid = await formRef.value.validate()
if (!valid) return
// 未查找到合适的上级
if (!bindUser.value) {
message.error('请先查询并确认推广人')
return
}
// 提交请求
formLoading.value = true
try {
// 发起修改
await BrokerageUserApi.updateBindUser(formData.value)
message.success(t('common.updateSuccess'))
dialogVisible.value = false
// 发送操作成功的事件
emit('success', true)
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
bindUserId: undefined
}
formRef.value?.resetFields()
bindUser.value = undefined
}
/** 查询推广员 */
const bindUser = ref<BrokerageUserApi.BrokerageUserVO>()
const handleGetUser = async () => {
if (formData.value.bindUserId == formData.value.id) {
message.error('不能绑定自己为推广人')
return
}
formLoading.value = true
bindUser.value = await BrokerageUserApi.getBrokerageUser(formData.value.bindUserId)
if (!bindUser.value) {
message.warning('推广员不存在')
}
formLoading.value = false
}
</script>
<template>
<el-dialog
v-model="visible"
:title="title"
width="min(760px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!submitting"
:show-close="!submitting"
:before-close="beforeClose"
destroy-on-close
>
<div v-loading="loading">
<el-alert
v-if="error"
:title="error"
type="error"
:closable="false"
show-icon
class="mb-16px"
/>
<template v-if="context">
<el-descriptions :column="1" border class="mb-16px">
<el-descriptions-item label="当前会员">{{ identity(context.user) }}</el-descriptions-item>
<el-descriptions-item label="原上级">{{ oldIdentity }}</el-descriptions-item>
<el-descriptions-item v-if="reviewing" label="调整后上级">
{{ mode === 'clear' ? '无上级' : identity(selected) }}
</el-descriptions-item>
</el-descriptions>
<template v-if="mode === 'update' && !reviewing">
<el-form inline @submit.prevent="search(1)">
<el-form-item label="查找新上级">
<el-select v-model="searchField" aria-label="搜索方式" style="width: 125px">
<el-option label="App ID" value="userCode" />
<el-option label="昵称" value="nickname" />
<el-option label="手机号" value="mobile" />
</el-select>
</el-form-item>
<el-form-item>
<el-input
v-model="keyword"
:aria-label="searchLabels[searchField]"
:placeholder="`请输入${searchLabels[searchField]}`"
clearable
/>
</el-form-item>
<el-form-item>
<el-button native-type="submit" :loading="searching" :disabled="!keyword.trim()"
>查询</el-button
>
</el-form-item>
</el-form>
<el-table
v-loading="searching"
:data="candidates"
highlight-current-row
@current-change="choose"
>
<el-table-column label="昵称" prop="nickname" min-width="140" />
<el-table-column label="App ID" prop="userCode" width="140" />
<el-table-column label="选择" width="130">
<template #default="{ row }">
<el-button link type="primary" :disabled="unavailable(row)" @click="choose(row)">
{{
selected?.id === row.id
? '已选择'
: row.id === context.user.id
? '当前会员'
: row.id === context.bindUserId
? '原上级'
: row.status !== 0
? '已禁用'
: '选择'
}}
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
class="mt-16px"
:total="total"
:current-page="pageNo"
:page-size="10"
layout="total, prev, pager, next"
@current-change="search"
/>
<p v-if="selected" class="selected-member">已选择新上级 {{ identity(selected) }}</p>
</template>
<el-form label-position="top" class="mt-16px">
<el-form-item label="操作原因" required>
<p v-if="reviewing" class="reason-text">{{ reason.trim() }}</p>
<el-input
v-else
v-model="reason"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="请填写本次调整的原因"
/>
</el-form-item>
</el-form>
<el-alert type="warning" :closable="false" show-icon>
<p
>已结算及待结算佣金保留原归属。调整会影响当前会员及下级后续支付的分佣关系,首次充值奖励资格保留。</p
>
<p>当前会员的下级关系保留。解除后的重新绑定遵循现有{{ bindModeText }}配置。</p>
</el-alert>
</template>
</div>
<template #footer>
<el-button :disabled="submitting" @click="close">取消</el-button>
<el-button v-if="reviewing" :disabled="submitting" @click="reviewing = false"
>返回修改</el-button
>
<el-button
v-if="!reviewing"
:type="mode === 'clear' ? 'danger' : 'primary'"
:disabled="loading || !context"
@click="review"
>核对变更</el-button
>
<el-button
v-else
:type="mode === 'clear' ? 'danger' : 'primary'"
:loading="submitting"
@click="submit"
>{{ mode === 'clear' ? '确认解除上级关系' : '确认保存上级' }}</el-button
>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import type { BindMember } from '@/api/mall/trade/brokerage/user'
import { useBrokerageBindForm } from './useBrokerageBindForm'
defineOptions({ name: 'BrokerageUserUpdateForm' })
const emit = defineEmits<{ success: [] }>()
const message = useMessage()
const {
visible,
loading,
searching,
submitting,
reviewing,
mode,
context,
searchField,
keyword,
candidates,
selected,
pageNo,
total,
reason,
error,
open: openForm,
search,
choose,
review,
submit,
close
} = useBrokerageBindForm(() => {
message.success('分销上级关系已保存')
emit('success')
})
const searchLabels = { userCode: 'App ID', nickname: '昵称', mobile: '手机号' }
const title = computed(() =>
mode.value === 'clear'
? '解除上级关系'
: context.value?.bindUserId
? '更换分销上级'
: '设置分销上级'
)
const oldIdentity = computed(() =>
context.value?.bindUser
? identity(context.value.bindUser)
: context.value?.bindUserId
? `会员资料缺失(内部编号 ${context.value.bindUserId})`
: '无上级'
)
const bindModeText = computed(
() =>
({ 1: '首次绑定', 2: '注册绑定', 3: '覆盖绑定' })[context.value?.bindMode || 0] || '绑定规则'
)
function identity(member?: BindMember | null) {
return member
? `${member.nickname || '未设置昵称'}(App ID ${member.userCode || '未设置'},内部编号 ${member.id})`
: '无上级'
}
function unavailable(member: BindMember) {
return (
member.id === context.value?.user.id ||
member.id === context.value?.bindUserId ||
member.status !== 0
)
}
function beforeClose(done: () => void) {
if (!submitting.value) {
close()
done()
}
}
function open(row: { id: number } | number, action: 'update' | 'clear' = 'update') {
return openForm(typeof row === 'number' ? row : row.id, action)
}
defineExpose({ open })
</script>
<style scoped>
.selected-member {
color: var(--el-color-primary);
}
.reason-text {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0;
}
.el-alert p {
margin: 4px 0;
}
</style>
+11 -11
View File
@@ -194,7 +194,7 @@
/>
</ContentWrap>
<!-- 修改上级推广人表单 -->
<BrokerageUserUpdateForm ref="updateFormRef" @success="getList" />
<BrokerageUserUpdateForm ref="updateFormRef" @success="refreshAfterBinding" />
<!-- 推广人列表 -->
<BrokerageUserListDialog ref="listDialogRef" />
<!-- 推广订单列表 -->
@@ -242,6 +242,14 @@ const getList = async () => {
}
}
const refreshAfterBinding = async () => {
try {
await getList()
} catch {
message.warning('关系已保存,列表刷新失败,请重新查询')
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
@@ -296,16 +304,8 @@ const openCreateUserForm = () => {
}
/** 清除上级推广人 */
const handleClearBindUser = async (row: BrokerageUserApi.BrokerageUserVO) => {
try {
// 二次确认
await message.confirm(`确认要清除"${row.nickname}"的上级推广人吗?`)
// 发起修改
await BrokerageUserApi.clearBindUser({ id: row.id })
message.success('清除成功')
// 刷新列表
await getList()
} catch {}
const handleClearBindUser = (row: BrokerageUserApi.BrokerageUserVO) => {
updateFormRef.value.open(row, 'clear')
}
/** 推广资格:开通/关闭 */
@@ -0,0 +1,188 @@
import { ref, watch } from 'vue'
import { generateUUID } from '@/utils'
import * as BrokerageApi from '@/api/mall/trade/brokerage/user'
export function useBrokerageBindForm(onSaved: () => void) {
const visible = ref(false)
const loading = ref(false)
const searching = ref(false)
const submitting = ref(false)
const reviewing = ref(false)
const mode = ref<'update' | 'clear'>('update')
const context = ref<BrokerageApi.BindContext>()
const searchField = ref<'userCode' | 'nickname' | 'mobile'>('userCode')
const keyword = ref('')
const candidates = ref<BrokerageApi.BindMember[]>([])
const selected = ref<BrokerageApi.BindMember>()
const pageNo = ref(1)
const total = ref(0)
const reason = ref('')
const error = ref('')
let generation = 0
let searchVersion = 0
let payload: BrokerageApi.BindChangeRequest | undefined
watch(
[searchField, keyword],
() => {
searchVersion += 1
searching.value = false
candidates.value = []
selected.value = undefined
total.value = 0
pageNo.value = 1
reviewing.value = false
payload = undefined
},
{ flush: 'sync' }
)
async function open(id: number, action: 'update' | 'clear' = 'update') {
if (submitting.value) return
const current = ++generation
searchVersion += 1
visible.value = true
loading.value = true
searching.value = false
reviewing.value = false
context.value = undefined
mode.value = action
keyword.value = ''
candidates.value = []
selected.value = undefined
total.value = 0
pageNo.value = 1
reason.value = ''
error.value = ''
payload = undefined
try {
const result = await BrokerageApi.getBindContext(id)
if (current === generation && visible.value) context.value = result
} catch (failure) {
if (current === generation) error.value = errorText(failure, '读取当前关系失败,请重新打开')
} finally {
if (current === generation) loading.value = false
}
}
async function search(page = 1) {
if (!context.value || !keyword.value.trim() || submitting.value) return
const current = generation
const version = ++searchVersion
searching.value = true
selected.value = undefined
candidates.value = []
total.value = 0
pageNo.value = page
error.value = ''
try {
const result = await BrokerageApi.getBindCandidatePage({
pageNo: page,
pageSize: 10,
searchField: searchField.value,
keyword: keyword.value.trim()
})
if (current !== generation || version !== searchVersion || !visible.value) return
candidates.value = result.list
total.value = result.total
} catch (failure) {
if (current === generation && version === searchVersion) {
error.value = errorText(failure, '查询失败,请重试')
}
} finally {
if (current === generation && version === searchVersion) searching.value = false
}
}
function choose(member: BrokerageApi.BindMember | undefined) {
if (submitting.value || reviewing.value) return
if (
!member ||
member.id === context.value?.user.id ||
member.id === context.value?.bindUserId ||
member.status !== 0
) {
selected.value = undefined
return
}
selected.value = member
}
function review() {
if (!context.value || submitting.value) return
if (mode.value === 'update' && !selected.value) {
error.value = '请查询并选择新上级'
return
}
if (mode.value === 'clear' && !context.value.bindUserId) {
error.value = '该会员当前没有上级,请刷新核对'
return
}
if (!reason.value.trim() || reason.value.trim().length > 500) {
error.value = '请填写500字以内的操作原因'
return
}
error.value = ''
payload = {
id: context.value.user.id,
bindUserId: mode.value === 'update' ? selected.value!.id : undefined,
expectedBindUserId: context.value.bindUserId ?? null,
expectedVersion: context.value.version,
reason: reason.value.trim(),
requestId: generateUUID()
}
reviewing.value = true
}
async function submit() {
if (!payload || !reviewing.value || submitting.value) return
submitting.value = true
error.value = ''
try {
if (mode.value === 'clear') await BrokerageApi.clearBindUser(payload)
else await BrokerageApi.updateBindUser(payload)
visible.value = false
generation += 1
onSaved()
} catch (failure) {
error.value = errorText(failure, '保存失败,请核对提示后重试')
} finally {
submitting.value = false
}
}
function close() {
if (submitting.value) return
visible.value = false
generation += 1
searchVersion += 1
}
return {
visible,
loading,
searching,
submitting,
reviewing,
mode,
context,
searchField,
keyword,
candidates,
selected,
pageNo,
total,
reason,
error,
open,
search,
choose,
review,
submit,
close
}
}
function errorText(failure: unknown, fallback: string) {
return failure instanceof Error ? failure.message : fallback
}
@@ -6,6 +6,35 @@
<p>收益按已结算累计,贡献以当前会员为收益人</p>
</div>
<div class="brokerage-tree__tools">
<el-button
v-if="root"
v-hasPermi="['trade:brokerage-user:update-bind-user']"
size="small"
type="primary"
plain
@click="bindFormRef?.open(root.id)"
>
{{ root.inviter ? '更换分销上级' : '设置分销上级' }}
</el-button>
<el-button
v-if="root?.inviter"
v-hasPermi="['trade:brokerage-user:clear-bind-user']"
size="small"
type="danger"
plain
@click="bindFormRef?.open(root.id, 'clear')"
>解除上级关系</el-button
>
<el-button
v-if="root"
v-hasPermi="[
'trade:brokerage-user:update-bind-user',
'trade:brokerage-user:clear-bind-user'
]"
size="small"
@click="bindLogRef?.open(root.id)"
>变更记录</el-button
>
<span class="brokerage-tree__legend"><i aria-hidden="true"></i>当前会员</span>
<el-switch
v-model="hideNoIncome"
@@ -222,6 +251,8 @@
></p
>
<UserBrokerageRecordDrawer ref="recordDrawerRef" />
<BrokerageUserUpdateForm ref="bindFormRef" @success="emit('relationSaved')" />
<BrokerageBindLogDrawer ref="bindLogRef" />
</section>
</template>
@@ -253,11 +284,16 @@ import { formatDate } from '@/utils/formatTime'
import { brokeragePriceToYuan } from '@/utils/formatter'
import { useBrokerageTree, type BrokerageTreeNode } from './useBrokerageTree'
import UserBrokerageRecordDrawer from './UserBrokerageRecordDrawer.vue'
import BrokerageUserUpdateForm from '@/views/mall/trade/brokerage/user/BrokerageUserUpdateForm.vue'
import BrokerageBindLogDrawer from '@/views/mall/trade/brokerage/user/BrokerageBindLogDrawer.vue'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
const props = defineProps<{ user: UserVO | null; active: boolean }>()
const emit = defineEmits<{ relationSaved: [] }>()
const bindFormRef = ref<InstanceType<typeof BrokerageUserUpdateForm>>()
const bindLogRef = ref<InstanceType<typeof BrokerageBindLogDrawer>>()
const {
root,
graph,
+13 -3
View File
@@ -74,10 +74,10 @@
<UserCouponList :user-id="id" />
</el-tab-pane>
<el-tab-pane label="分销关系" name="brokerage" lazy>
<UserBrokerageTree :user="user" :active="activeDetailTab === 'brokerage'" />
<UserBrokerageTree :user="user" :active="activeDetailTab === 'brokerage'" @relation-saved="handleRelationSaved" />
</el-tab-pane>
<el-tab-pane label="推广用户" lazy>
<UserBrokerageList :bind-user-id="id" />
<UserBrokerageList :key="relationshipVersion" :bind-user-id="id" />
</el-tab-pane>
</el-tabs>
</el-card>
@@ -119,6 +119,7 @@ defineOptions({ name: 'MemberDetail' })
const UserBrokerageTree = defineAsyncComponent(() => import('./UserBrokerageTree.vue'))
const activeDetailTab = ref('0')
const relationshipVersion = ref(0)
const loading = ref(true) // 加载中
const user = ref<UserApi.UserVO>({} as UserApi.UserVO)
@@ -138,11 +139,20 @@ const getUserData = async (id: number) => {
}
}
const handleRelationSaved = async () => {
relationshipVersion.value += 1
try {
await getUserData(id)
} catch {
ElMessage.warning('关系已保存,页面刷新失败,请重新进入详情核对')
}
}
/** 初始化 */
const { currentRoute } = useRouter() // 路由
const { delView } = useTagsViewStore() // 视图操作
const route = useRoute()
const id = route.params.id
const id = Number(route.params.id)
const syncDetailTab = () => {
if (
+164
View File
@@ -0,0 +1,164 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate } = require('vue/compiler-sfc')
const filename = path.join(__dirname, '../src/views/app/setting/index.vue')
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'))
const transpile = (source) => ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
}).outputText
function evaluate(source, imports, globals = {}) {
const module = { exports: {} }
vm.runInNewContext(transpile(source), {
exports: module.exports,
require: (name) => {
if (!(name in imports)) throw new Error(`Unexpected import: ${name}`)
return imports[name]
},
...globals
})
return module.exports
}
const api = evaluate(fs.readFileSync(path.join(__dirname, '../src/api/app/setting/index.ts'), 'utf8'), {
'@/config/axios': { default: {} }
})
function harness(overrides = {}, editable = true) {
const calls = []
const backend = {
...api,
getSetting: async () => null,
getRooms: async () => [],
saveSetting: async (data) => { calls.push(['setting', data]); return true },
getBookingPage: async () => ({ list: [], total: 0 }),
getClosurePage: async () => ({ list: [], total: 0 }),
updateBookingStatus: async (data) => { calls.push(['booking', data]); return true },
updateClosureStatus: async (data) => { calls.push(['closure', data]); return true },
...overrides
}
const component = evaluate(compileScript(descriptor, { id: 'app-setting' }).content, {
vue: { ...vue, onMounted: () => {} },
'vue-router': { onBeforeRouteLeave: () => {} },
'element-plus': { ElMessageBox: { prompt: async () => ({ value: '客人要求取消' }) } },
'@/components/UploadFile': { UploadImg: {} },
'@/utils/permission': { checkPermi: () => editable },
'@/utils/formatTime': { formatDate: (value) => value },
'@/api/app/setting': backend
}, {
useMessage: () => ({ success: () => {}, warning: () => {}, confirm: async () => true })
}).default
return { page: component.setup({}, { expose: () => {} }), calls, backend }
}
const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done }); return { promise, resolve } }
test('settings template compiles with all five management sections', () => {
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'app-setting' }).errors, [])
for (const section of ['运行模式', '门店信息', '包间管理', '预约管理', '注销申请']) assert.ok(descriptor.template.content.includes(section))
})
test('price inputs convert decimal currency exactly and reject unsupported values', () => {
assert.equal(api.yuanToFen('0.29'), 29)
assert.equal(api.yuanToFen('1.01'), 101)
assert.equal(api.yuanToFen('21474836.47'), 2147483647)
assert.equal(api.yuanToFen('21474836.48'), null)
for (const invalid of ['-1', '1.001', '1e4', '', '1,000', 'Infinity']) assert.equal(api.yuanToFen(invalid), null)
assert.equal(api.fenToYuan(1), '0.01')
assert.equal(api.fenToYuan('900719925474099301'), '9007199254740993.01')
})
test('tea activation requires configured store, valid non-overlapping slots and an enabled room', async () => {
const { page, calls } = harness()
await page.loadSettings(); await page.loadRooms()
page.settings.iosMode = 'tea'
await page.submitSettings()
assert.equal(calls.length, 0)
assert.ok(page.settingErrors.value.activation)
Object.assign(page.settings, {
storeName: '茶舍', storeCover: '/cover.jpg', storeAddress: '门店地址', storePhone: '12345678',
openTime: '09:00', closeTime: '18:00', slots: [{ startTime: '10:00', endTime: '12:00' }, { startTime: '11:30', endTime: '14:00' }]
})
page.rooms.value = [{ id: 1, enabled: true }]
await page.submitSettings()
assert.equal(calls.length, 0)
assert.match(page.settingErrors.value.slots, /重叠/)
page.settings.slots[1].startTime = '12:00'
await page.submitSettings()
assert.equal(calls.length, 1)
assert.equal(calls[0][1].iosMode, 'tea')
assert.equal(calls[0][1].androidMode, 'chat')
assert.equal(page.settingsDirty.value, false)
})
test('save failure retains user draft and unsaved state', async () => {
const { page } = harness({ saveSetting: async () => { throw new Error('network') } })
await page.loadSettings()
page.settings.storeName = '尚未保存的茶舍'
await page.submitSettings()
assert.equal(page.settings.storeName, '尚未保存的茶舍')
assert.equal(page.settingsDirty.value, true)
assert.equal(page.settingsSaving.value, false)
})
test('chat mode settings retain valid default hours and cannot save blank hours', async () => {
const { page, calls } = harness()
await page.loadSettings()
assert.equal(page.settings.openTime, '10:00')
assert.equal(page.settings.closeTime, '22:00')
page.settings.openTime = ''
page.settings.closeTime = ''
await page.submitSettings()
assert.equal(calls.length, 0)
assert.ok(page.settingErrors.value.businessHours)
})
test('late booking responses cannot overwrite the current filter result', async () => {
const first = deferred(); const second = deferred(); let count = 0
const { page } = harness({ getBookingPage: () => (++count === 1 ? first.promise : second.promise) })
const a = page.loadBookings(); const b = page.loadBookings()
second.resolve({ list: [{ id: 2 }], total: 1 }); await b
first.resolve({ list: [{ id: 1 }], total: 3 }); await a
assert.equal(page.bookings.value[0].id, 2)
assert.equal(page.bookingTotal.value, 1)
})
test('booking actions use server capabilities and do not submit unavailable actions', async () => {
const { page, calls } = harness()
const booking = { id: 1, canConfirm: false, canReject: true, canComplete: false, canAdminCancel: false }
await page.changeBooking(booking, 'confirmed')
assert.equal(calls.length, 0)
await page.changeBooking(booking, 'rejected')
assert.equal(calls.length, 1)
assert.equal(calls[0][1].status, 'rejected')
assert.equal(calls[0][1].reason, '客人要求取消')
})
test('account closure requires processing remarks and only records an outcome', async () => {
const { page, calls } = harness()
page.openClosure({ id: 7, status: 'pending' })
await page.submitClosure()
assert.equal(calls.length, 0)
assert.ok(page.closureRemarkError.value)
page.closureRemark.value = '已核实申请并完成必要的人工处理'
page.closureNextStatus.value = 'resolved'
await page.submitClosure()
assert.equal(calls.length, 1)
assert.equal(calls[0][0], 'closure')
assert.equal(calls[0][1].status, 'resolved')
assert.equal(page.closureDialog.value, false)
})
test('read-only operators cannot save settings or process requests', async () => {
const { page, calls } = harness({}, false)
await page.loadSettings()
page.settings.storeName = '无写权限'
await page.submitSettings()
page.openClosure({ id: 7, status: 'pending' })
page.closureRemark.value = '测试'
await page.submitClosure()
await page.changeBooking({ id: 1, canConfirm: true }, 'confirmed')
assert.equal(calls.length, 0)
})
+154
View File
@@ -0,0 +1,154 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileTemplate } = require('vue/compiler-sfc')
const directory = path.join(__dirname, '../src/views/mall/trade/brokerage/user')
const source = fs.readFileSync(path.join(directory, 'useBrokerageBindForm.ts'), 'utf8')
const member = (id, userCode = `APP${id}`) => ({ id, userCode, nickname: `会员${id}`, status: 0 })
const snapshot = (id = 300) => ({ user: member(id, '999827'), bindUser: member(10), bindUserId: 10, version: '8', bindMode: 1 })
function deferred() { let resolve, reject; const promise = new Promise((done, fail) => { resolve = done; reject = fail }); return { promise, resolve, reject } }
function harness(overrides = {}) {
const writes = []
let saved = 0
let serial = 0
const api = {
getBindContext: async (id) => snapshot(id),
getBindCandidatePage: async () => ({ list: [member(20)], total: 1 }),
updateBindUser: async (data) => { writes.push({ ...data }) },
clearBindUser: async (data) => { writes.push({ ...data }) },
...overrides
}
const module = { exports: {} }
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText, {
exports: module.exports,
require: (name) => {
if (name === 'vue') return vue
if (name === '@/api/mall/trade/brokerage/user') return api
if (name === '@/utils') return { generateUUID: () => `00000000-0000-4000-8000-${String(++serial).padStart(12, '0')}` }
throw new Error(`Unexpected import ${name}`)
}
})
const scope = vue.effectScope()
const form = scope.run(() => module.exports.useBrokerageBindForm(() => { saved++ }))
return { form, writes, api, stop: () => scope.stop(), saved: () => saved }
}
async function select(form) {
form.keyword.value = 'APP20'
await form.search()
form.choose(form.candidates.value[0])
}
test('form and history templates compile and both pages use the shared form', () => {
for (const file of ['BrokerageUserUpdateForm.vue', 'BrokerageBindLogDrawer.vue']) {
const full = path.join(directory, file)
const { descriptor } = parse(fs.readFileSync(full, 'utf8'))
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: full, id: 'bind' }).errors, [])
}
const detail = fs.readFileSync(path.join(__dirname, '../src/views/member/user/detail/UserBrokerageTree.vue'), 'utf8')
assert.match(detail, /bindFormRef\?\.open\(root.id/)
assert.match(detail, /trade:brokerage-user:update-bind-user/)
assert.match(detail, /trade:brokerage-user:clear-bind-user/)
const list = fs.readFileSync(path.join(directory, 'index.vue'), 'utf8')
assert.match(list, /updateFormRef.value.open\(row, 'clear'\)/)
assert.doesNotMatch(list, /clearBindUser\(\{ id: row.id \}\)/)
})
test('changing search after confirming A clears A and cannot submit B without selecting B', async () => {
const h = harness(); const f = h.form
await f.open(300)
await select(f)
assert.equal(f.selected.value.id, 20)
f.keyword.value = 'APP30'
assert.equal(f.selected.value, undefined)
f.reason.value = '核实关系'
f.review(); await f.submit()
assert.equal(h.writes.length, 0)
h.stop()
})
test('late candidate results and late member context never overwrite the active search/member', async () => {
const old = deferred(); const current = deferred(); let calls = 0
const h = harness({ getBindCandidatePage: () => ++calls === 1 ? old.promise : current.promise })
const f = h.form
await f.open(300)
f.keyword.value = 'A'; const first = f.search()
f.keyword.value = 'B'; const second = f.search()
current.resolve({ list: [member(30)], total: 1 }); await second
old.resolve({ list: [member(20)], total: 1 }); await first
assert.equal(f.candidates.value[0].id, 30)
assert.equal(f.searching.value, false)
const stale = deferred()
h.api.getBindContext = (id) => id === 300 ? stale.promise : Promise.resolve(snapshot(id))
const previous = f.open(300); await f.open(400)
stale.resolve(snapshot(300)); await previous
assert.equal(f.context.value.user.id, 400)
h.stop()
})
test('requires reason, selected member and review, then sends internal IDs and version once', async () => {
const pending = deferred()
const sent = []
const h = harness({ updateBindUser: (data) => { sent.push({ ...data }); return pending.promise } })
const f = h.form
await f.open(300)
await select(f)
f.review(); await f.submit()
assert.equal(sent.length, 0)
f.reason.value = ' 经核实调整 '
f.review()
const first = f.submit(); const duplicate = f.submit()
f.close()
assert.equal(f.visible.value, true)
assert.equal(sent.length, 1)
assert.equal(sent[0].id, 300)
assert.equal(sent[0].bindUserId, 20)
assert.equal(sent[0].expectedBindUserId, 10)
assert.equal(sent[0].expectedVersion, '8')
assert.equal(sent[0].reason, '经核实调整')
pending.resolve(); await Promise.all([first, duplicate])
assert.equal(f.visible.value, false)
assert.equal(h.saved(), 1)
h.stop()
})
test('clear uses the same reviewed snapshot and cancel never writes', async () => {
const h = harness(); const f = h.form
await f.open(300, 'clear')
f.reason.value = '解除原关系'; f.review(); f.close()
assert.equal(h.writes.length, 0)
await f.open(300, 'clear')
f.reason.value = '解除原关系'; f.review(); await f.submit()
assert.equal(h.writes.length, 1)
assert.equal(h.writes[0].bindUserId, undefined)
assert.equal(h.writes[0].expectedBindUserId, 10)
h.stop()
})
test('retry after timeout preserves the request ID and input until saved', async () => {
const sent = []; let attempts = 0
const h = harness({ updateBindUser: async (data) => { sent.push({ ...data }); if (++attempts === 1) throw new Error('关系已变更') } })
const f = h.form
await f.open(300); await select(f)
f.reason.value = '核实'; f.review(); await f.submit()
assert.equal(f.visible.value, true)
assert.equal(f.reason.value, '核实')
assert.equal(f.selected.value.id, 20)
await f.submit()
assert.equal(sent[0].requestId, sent[1].requestId)
assert.equal(h.saved(), 1)
h.stop()
})
test('current member, current parent and disabled members cannot be selected', async () => {
const h = harness(); const f = h.form
await f.open(300)
for (const candidate of [member(300), member(10), { ...member(20), status: 1 }]) {
f.choose(candidate)
assert.equal(f.selected.value, undefined)
}
h.stop()
})
+1
View File
@@ -433,6 +433,7 @@ test('Dagre布局无节点重叠,筛选移除节点后重新适应画布,详
const fitCalls = []
const context = {
exports: {},
defineEmits: () => () => {},
defineProps: () => props,
useRouter: () => ({ push: (route) => routes.push(route) }),
require: (name) => {