522 lines
20 KiB
Plaintext
522 lines
20 KiB
Plaintext
<template>
|
||
<view class="members-page">
|
||
<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 是小写的 uid,tid 也是小写的
|
||
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>
|
||
|
||
|
||
|