Files
medical-mall/pages/mall/merchant/members.uvue

530 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="members-page">
<!-- #ifdef MP-WEIXIN -->
<view style="padding-top: var(--status-bar-height); background-color: #ffffff; display: flex; flex-direction: row; align-items: flex-end; border-bottom: 1rpx solid #eeeeee; box-sizing: border-box; height: calc(88rpx + var(--status-bar-height));">
<view style="display: flex; flex-direction: row; align-items: center; padding: 0 30rpx; height: 88rpx;" @click="uni.navigateBack()">
<text style="font-size: 44rpx; color: #333333; line-height: 1; margin-right: 6rpx;"></text>
<text style="font-size: 28rpx; color: #333333;">返回</text>
</view>
</view>
<!-- #endif -->
<view class="tabs">
<view class="tab" :class="{ active: activeTab === 0 }" @click="activeTab = 0">等级设置</view>
<view class="tab" :class="{ active: activeTab === 1 }" @click="activeTab = 1">客户列表</view>
</view>
<scroll-view scroll-y class="list-container" v-if="activeTab === 0">
<view class="section-card">
<view class="card-header">
<text class="card-title">等级配置</text>
<text class="add-btn" @click="showAddLevel = true">+ 添加等级</text>
</view>
<view class="level-list">
<view v-for="level in levels" :key="level.id" class="level-item">
<view class="level-info">
<text class="level-name">{{ level.name }}</text>
<text class="level-rate">{{ (level.discount_rate * 10).toFixed(1) }}折</text>
</view>
<view class="level-actions">
<text class="action-edit" @click="editLevel(level)">编辑</text>
<text class="action-del" @click="deleteLevel(level.id)">删除</text>
</view>
</view>
</view>
</view>
</scroll-view>
<scroll-view scroll-y class="list-container" v-if="activeTab === 1">
<view class="user-list">
<view v-if="users.length === 0" class="empty-tip">暂无注册客户</view>
<view v-for="user in users" :key="user.id" class="user-item">
<image :src="user.avatar_url || '/static/images/default-avatar.png'" class="user-avatar" />
<view class="user-info">
<view class="user-title-row">
<text class="user-name">{{ user.nickname || user.username || '未设置昵称' }}</text>
<view class="user-tier-tag" v-if="user.tier_name">{{ user.tier_name }}</view>
</view>
<text class="user-email" v-if="user.email">{{ user.email }}</text>
<text class="user-phone">{{ user.phone || '未绑定手机' }}</text>
</view>
<view class="user-actions">
<text class="action-set" @click="showSetTier(user)">设置VIP</text>
<text class="action-set discount-btn" @click="goToExclusive(user)">专属折扣</text>
</view>
</view>
</view>
</scroll-view>
<!-- 编辑等级弹窗 -->
<view class="modal" v-if="showEditModal">
<view class="modal-content">
<view class="modal-title">{{ currentLevel.id ? '编辑等级' : '添加等级' }}</view>
<view class="form-item">
<text class="label">等级名称</text>
<input class="input" v-model="currentLevel.name" placeholder="请输入名称" />
</view>
<view class="form-item">
<text class="label">折扣率 (0-1)</text>
<input class="input" type="digit" v-model="currentLevel.discount_rate" placeholder="如0.85表示85折" />
</view>
<view class="modal-btns">
<text class="btn cancel" @click="showEditModal = false">取消</text>
<text class="btn confirm" @click="saveLevel">保存</text>
</view>
</view>
</view>
<!-- 设置用户等级弹窗 -->
<view class="modal" v-if="showTierModal">
<view class="modal-content">
<view class="modal-title">设置会员等级</view>
<view class="tier-options">
<view v-for="level in levels" :key="level.id"
class="tier-option"
:class="{ selected: selectedTierId === level.id }"
@click="selectedTierId = level.id">
{{ level.name }}
</view>
</view>
<view class="modal-btns">
<text class="btn cancel" @click="showTierModal = false">取消</text>
<text class="btn confirm" @click="confirmSetTier">确认</text>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type MemberLevel = {
id: string
name: string
discount_rate: number
level_rank: number
}
type UserInfo = {
id: string
username: string
email: string
nickname: string | null
avatar_url: string | null
phone: string | null
tier_id: string | null
tier_name: string | null
}
export default {
data() {
return {
activeTab: 0,
levels: [] as MemberLevel[],
users: [] as UserInfo[],
searchKey: '',
showEditModal: false,
showTierModal: false,
showAddLevel: false,
currentLevel: {
id: '',
name: '',
discount_rate: 1.0,
level_rank: 0
} as MemberLevel,
currentUser: null as UserInfo | null,
selectedTierId: '',
merchantId: ''
}
},
onLoad() {
this.merchantId = uni.getStorageSync('user_id') || ''
this.loadLevels()
},
watch: {
activeTab(val: number) {
if (val === 1 && this.users.length === 0) {
this.loadUsers()
}
}
},
methods: {
handleSearch() {
console.log('按钮被点击,触发 handleSearch');
this.loadUsers();
},
async loadLevels() {
const res = await supa.from('ml_member_levels').select('*').order('level_rank', { ascending: true }).execute()
if (res.data != null) {
this.levels = (res.data as any[]).map((item: any) => {
const obj = item as UTSJSONObject
return {
id: obj.getString('id') || '',
name: obj.getString('name') || '',
discount_rate: obj.getNumber('discount_rate') || 1.0,
level_rank: obj.getNumber('level_rank') || 0
} as MemberLevel
})
}
},
async loadUsers() {
console.log('--- 启动 ak_users 全量加载 (不带 limit 限制) ---');
try {
// 1. 移除 limit 限制或设置极大值,确保读到全部数据
// 同时通过 count 参数确认数据库到底给了多少条
const res = await supa.from('ak_users')
.select('id, username, nickname, email, phone, avatar_url, role', { count: 'exact' })
.execute()
if (res.error != null) {
console.error('API请求错误:', res.error);
return
}
if (res.data != null) {
let rawData = res.data as any[]
console.log('数据库查询成功。总行数:', res.count, ' 返回行数:', rawData.length);
// 增加一个调试点:统计一下所有数据的 role 分布,看看到底有多少个 role 是 customer
let customerCount = 0;
rawData.forEach((item: any) => {
const r = String((item as UTSJSONObject)['role'] || '').trim().toLowerCase();
if (r == 'customer') customerCount++;
});
console.log('内存扫描结果: 含有 customer 字样的记录总数:', customerCount);
// 2. 获取会员等级地图
let profileMap = new Map<string, string>()
try {
const profileRes = await supa.from('ml_user_profiles').select('*').limit(1).execute()
if (profileRes.data != null && (profileRes.data as any[]).length > 0) {
console.log('【数据库结构探查】ml_user_profiles 第一条数据:', JSON.stringify(profileRes.data[0]))
}
const profileAllRes = await supa.from('ml_user_profiles').select('*').execute()
console.log('【数据调试】ml_user_profiles 返回行数:', (profileAllRes.data as any[] || []).length)
if (profileAllRes.data != null) {
const profileData = profileAllRes.data as any[]
profileData.forEach((p: any) => {
if (p != null) {
const po = p as UTSJSONObject
const uid = String(po['user_id'] || '').trim().toLowerCase()
const keys = Object.keys(p as object)
let foundTid = ''
if (keys.includes('tier_id')) {
foundTid = String(po['tier_id'] || '')
} else if (keys.includes('level_id')) {
foundTid = String(po['level_id'] || '')
} else if (keys.includes('rank_id')) {
foundTid = String(po['rank_id'] || '')
} else {
const autoKey = keys.find(k => k.includes('level') || k.includes('tier'))
if (autoKey != null) {
foundTid = String(po[autoKey] || '')
}
}
foundTid = foundTid.trim().toLowerCase()
if (uid != '' && foundTid != '' && foundTid != 'null') {
console.log(`【映射匹配成功】UID: ${uid} -> TID: ${foundTid}`)
profileMap.set(uid, foundTid)
}
}
})
}
} catch (e) {
console.error('查询 profile 报错:', e)
}
// 3. 【极致完善筛选逻辑】
this.users = rawData.map((u: any): UserInfo | null => {
if (u == null) return null
const uo = u as UTSJSONObject
let rawRole = String(uo['role'] || '');
const role = rawRole.trim().toLowerCase();
// 严格筛选:仅保留角色为 consumer 的真实消费者
if (role != 'consumer') return null
const uid = String(uo['id'] || uo['user_id'] || '').trim().toLowerCase()
const username = String(uo['username'] || '')
// 这里是关键profileMap 里的 key 是小写的 uidtid 也是小写的
const tid = profileMap.get(uid) || ''
let tname = ''
if (tid != '') {
// 1. 严格 ID 匹配
const level = this.levels.find(l => (l.id || '').trim().toLowerCase() === tid)
if (level != null) {
tname = level.name
} else {
// 2. 备用:如果 ID 匹配不到,尝试看这个 tid 是不是等级的序号(level_rank)
const levelByRank = this.levels.find(l => String(l.level_rank) === tid)
if (levelByRank != null) tname = levelByRank.name
}
}
if (tid != '') {
console.log(`【渲染行检查】用户:${username}, ID:${uid}, 等级TID(DB):${tid}, 匹配结果:${tname}`)
}
return {
id: uid,
username: username,
email: String(uo['email'] || ''),
nickname: String(uo['nickname'] || uo['username'] || '未设置昵称'),
avatar_url: String(uo['avatar_url'] || uo['head_img_url'] || ''),
phone: String(uo['phone'] || ''),
tier_id: tid,
tier_name: tname
} as UserInfo
}).filter((u: any): boolean => u != null) as UserInfo[]
// 【核心优化】自动将已经设置了 VIP 的人排在列表最顶端,方便一眼看到
this.users.sort((a, b) => {
const nameA = (a.tier_name || '').trim()
const nameB = (b.tier_name || '').trim()
if (nameA != '' && nameB == '') return -1
if (nameA == '' && nameB != '') return 1
return 0
})
console.log('【最终渲染检查】当前用户列表长度:', this.users.length);
// 强制触发一次 UI 重绘
this.$forceUpdate();
}
} catch (e) {
console.error('加载逻辑崩溃:', e);
}
},
processUserData(rawData: any[]) {
if (rawData != null && Array.isArray(rawData)) {
this.users = rawData.map((item: any) => {
const istr = JSON.stringify(item)
const obj = JSON.parse(istr) as UTSJSONObject
const tierId = obj.getString('tier_id')
let tierName = ''
if (tierId != null && tierId != '') {
const level = this.levels.find(l => l.id === tierId)
if (level != null) tierName = level.name
}
return {
id: obj.getString('id') || obj.getString('user_id') || '',
nickname: obj.getString('nickname') || '未设置昵称',
avatar_url: obj.getString('avatar_url'),
phone: obj.getString('phone_number') || '无手机号',
tier_id: tierId,
tier_name: tierName
} as UserInfo
})
} else {
this.users = []
}
},
editLevel(level: MemberLevel) {
this.currentLevel = JSON.parse(JSON.stringify(level)) as MemberLevel
this.showEditModal = true
},
async saveLevel() {
if (!this.currentLevel.name) return
// 构造提交数据,确保类型正确
const discount = parseFloat(this.currentLevel.discount_rate.toString())
const rank = parseInt(this.currentLevel.level_rank.toString())
const data = {
name: this.currentLevel.name,
discount_rate: isNaN(discount) ? 1.0 : discount,
level_rank: isNaN(rank) ? 0 : rank
}
let res: any
if (this.currentLevel.id) {
res = await supa.from('ml_member_levels').update(data).eq('id', this.currentLevel.id).execute()
} else {
res = await supa.from('ml_member_levels').insert(data).execute()
}
if (res.error == null) {
uni.showToast({ title: '保存成功' })
this.showEditModal = false
this.loadLevels()
} else {
uni.showModal({ title: '保存失败', content: JSON.stringify(res.error) })
}
},
async deleteLevel(id: string) {
uni.showModal({
title: '确认删除',
content: '此操作将同步删除关联用户的等级,是否继续?',
success: async (res) => {
if (res.confirm) {
// 先将该等级下的用户 tier_id 清空,防止外键约束或逻辑残留
await supa.from('ml_user_profiles').update({ tier_id: null }).eq('tier_id', id).execute()
const delRes = await supa.from('ml_member_levels').delete().eq('id', id).execute()
if (delRes.error == null) {
this.loadLevels()
this.loadUsers()
}
}
}
})
},
goToExclusive(user: any) {
const name = user['nickname'] || user['username'] || user['phone'] || '客户'
const uId = user['id']
uni.navigateTo({
url: '/pages/mall/merchant/exclusive-discounts?user_id=' + uId + '&user_name=' + encodeURIComponent(name as string)
})
},
showSetTier(user: UserInfo) {
this.currentUser = user
this.selectedTierId = user.tier_id || ''
this.showTierModal = true
},
async confirmSetTier() {
if (this.currentUser == null) return
uni.showLoading({ title: '确认中...' })
try {
const userObj = this.currentUser as UserInfo
const userId = userObj.id
// 1. 获取所有字段名(不依赖第一行数据,而是通过 RPC 或直接查询)
// 为确保万无一失,我们直接同时尝试写入 tier_id 和 level_id
const probeRes = await supa.from('ml_user_profiles').select('*').limit(1).execute()
let finalObj = {
'user_id': userId,
'updated_at': new Date().toISOString()
} as UTSJSONObject
// 智能探测字段
if (probeRes.data != null && (probeRes.data as any[]).length > 0) {
const keys = Object.keys(probeRes.data![0] as object)
console.log('【数据库字段探测】:', JSON.stringify(keys))
if (keys.includes('tier_id')) {
finalObj['tier_id'] = this.selectedTierId
} else if (keys.includes('level_id')) {
finalObj['level_id'] = this.selectedTierId
} else if (keys.includes('rank_id')) {
finalObj['rank_id'] = this.selectedTierId
} else {
// 万能匹配
const anyLevelKey = keys.find(k => k.includes('level') || k.includes('tier'))
if (anyLevelKey != null) finalObj[anyLevelKey] = this.selectedTierId
}
} else {
// 如果表完全是空的,默认尝试 tier_id
finalObj['tier_id'] = this.selectedTierId
}
// 2. 使用 UPSERT 逻辑(存在就更新,没有就插入)
// Supabase 的 upsert 需要定义唯一约束,这里我们根据 user_id 处理
const checkExist = await supa.from('ml_user_profiles').select('id').eq('user_id', userId).execute()
let finalRes: any = null
if (checkExist.data != null && (checkExist.data as any[]).length > 0) {
// 注意:更新时不需要带上 user_id 字段
const updateObj = JSON.parse(JSON.stringify(finalObj)) as UTSJSONObject
delete updateObj['user_id']
finalRes = await supa.from('ml_user_profiles').update(updateObj).eq('user_id', userId).execute()
} else {
finalRes = await supa.from('ml_user_profiles').insert(finalObj).execute()
}
if (finalRes != null && finalRes.error != null) {
throw new Error('保存失败: ' + finalRes.error!.message)
}
uni.hideLoading()
uni.showToast({ title: '设置成功', icon: 'success' })
this.showTierModal = false
// 立即重新获取该用户的 profile 确认
setTimeout(() => {
this.loadUsers()
}, 300)
} catch (e) {
uni.hideLoading()
uni.showModal({
title: '设置异常',
content: String(e),
showCancel: false
})
}
}
}
}
</script>
<style>
.members-page { background-color: #f8f9fa; min-height: 100vh; }
.tabs { display: flex; background: #fff; padding: 20rpx 0; border-bottom: 1rpx solid #eee; }
.tab { flex: 1; text-align: center; font-size: 28rpx; color: #666; }
.tab.active { color: #007AFF; font-weight: bold; }
.list-container { padding: 20rpx; }
.section-card { background: #fff; border-radius: 16rpx; padding: 30rpx; }
.card-header { display: flex; justify-content: space-between; margin-bottom: 30rpx; align-items: center; }
.card-title { font-size: 32rpx; font-weight: bold; }
.add-btn { color: #007AFF; font-size: 26rpx; }
.level-item { display: flex; justify-content: space-between; border-bottom: 1rpx solid #f5f5f5; padding: 20rpx 0; }
.level-name { font-size: 30rpx; display: block; }
.level-rate { font-size: 24rpx; color: #FF9500; }
.level-actions .text { font-size: 24rpx; margin-left: 20rpx; }
.action-edit { color: #007AFF; margin-right: 20rpx; }
.action-del { color: #FF3B30; }
.search-bar { display: flex; padding: 20rpx; background: #fff; margin-bottom: 20rpx; border-radius: 12rpx; }
.search-input { flex: 1; height: 72rpx; background: #f5f5f5; border-radius: 36rpx; padding: 0 30rpx; font-size: 26rpx; }
.search-btn { margin-left: 20rpx; color: #007AFF; line-height: 72rpx; font-size: 28rpx; }
.user-item { display: flex; align-items: center; background: #fff; padding: 24rpx; border-radius: 16rpx; margin-bottom: 20rpx; }
.user-avatar { width: 90rpx; height: 90rpx; border-radius: 45rpx; background: #eee; }
.user-info { flex: 1; margin-left: 24rpx; }
.user-name { font-size: 30rpx; font-weight: bold; display: block; }
.user-phone { font-size: 24rpx; color: #999; }
.user-tier-tag { display: inline-block; background: #FF9500; color: #fff; font-size: 20rpx; padding: 2rpx 12rpx; border-radius: 4rpx; margin-top: 8rpx; }
.action-set {
font-size: 24rpx;
color: #2196F3;
padding: 10rpx 20rpx;
background-color: #E3F2FD;
border-radius: 8rpx;
}
.action-set.discount-btn {
color: #FF9800;
background-color: #FFF8E1;
margin-left: 16rpx;
}
/* 弹窗样式 */
.modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 999; }
.modal-content { background: #fff; width: 600rpx; border-radius: 24rpx; padding: 40rpx; }
.modal-title { text-align: center; font-size: 34rpx; font-weight: bold; margin-bottom: 30rpx; }
.form-item { margin-bottom: 30rpx; }
.label { font-size: 26rpx; color: #666; margin-bottom: 12rpx; display: block; }
.input { background: #f5f5f5; height: 80rpx; border-radius: 12rpx; padding: 0 20rpx; font-size: 28rpx; }
.modal-btns { display: flex; justify-content: flex-end; margin-top: 40rpx; }
.btn { padding: 16rpx 40rpx; border-radius: 12rpx; font-size: 28rpx; margin-left: 20rpx; }
.btn.cancel { background: #eee; color: #666; }
.btn.confirm { background: #007AFF; color: #fff; }
.tier-options { display: flex; flex-wrap: wrap; }
.tier-option { padding: 16rpx 30rpx; background: #f5f5f5; margin: 10rpx; border-radius: 36rpx; font-size: 26rpx; }
.tier-option.selected { background: #007AFF; color: #fff; }
</style>