合并merchant文件

This commit is contained in:
2026-03-20 15:43:33 +08:00
parent 29f588a2b2
commit 620ae742df
12 changed files with 3477 additions and 0 deletions

View File

@@ -0,0 +1,521 @@
<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 是小写的 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>