Files
medical-mall/pages/mall/admin/user/management/index.uvue

718 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="admin-page">
<view class="admin-sections">
<!-- 筛选面板 -->
<view class="admin-card filter-card">
<view class="filter-row">
<view class="filter-item">
<text class="label">用户搜索:</text>
<view class="input-group">
<view class="compact-select">
<text>请选择</text>
<text class="arrow">▼</text>
</view>
<input class="filter-input" placeholder="请输入用户" />
</view>
</view>
<view class="filter-item">
<text class="label">用户等级:</text>
<view class="filter-select">
<text class="select-placeholder">请选择用户等级</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="filter-item">
<text class="label">用户分组:</text>
<view class="filter-select">
<text class="select-placeholder">请选择用户分组</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="filter-btns">
<button class="btn primary" @click="onSearch">搜索</button>
<button class="btn" @click="onReset">重置</button>
<text class="expand-btn">展开 </text>
</view>
</view>
</view>
<!-- 内容卡片 -->
<view class="admin-card content-card">
<!-- 平台切换 Tabs -->
<view class="tabs-row">
<view
v-for="(tab, index) in tabs"
:key="index"
class="tab-item"
:class="{ active: activeTab === index }"
@click="activeTab = index"
>
<text>{{ tab }}</text>
</view>
</view>
<!-- 操作按钮行 -->
<view class="action-bar">
<button class="btn primary small" @click="onAddUser">添加用户</button>
<button class="btn ghost small">发送优惠券</button>
<button class="btn ghost small">发送图文消息</button>
<button class="btn ghost small">批量设置分组</button>
<button class="btn ghost small">批量设置标签</button>
<button class="btn ghost small">导出</button>
</view>
<!-- 用户列表表格 -->
<view class="table-container">
<!-- 表头 -->
<view class="table-header">
<view class="col col-check"><checkbox :checked="isAllChecked" /></view>
<view class="col col-expand"></view>
<view class="col col-id"><text>用户ID</text></view>
<view class="col col-avatar"><text>头像</text></view>
<view class="col col-name"><text>姓名</text></view>
<view class="col col-member"><text>付费会员</text></view>
<view class="col col-level"><text>用户等级</text></view>
<view class="col col-group"><text>分组</text></view>
<view class="col col-spread"><text>分销等级</text></view>
<view class="col col-phone"><text>手机号</text></view>
<view class="col col-type"><text>用户类型</text></view>
<view class="col col-balance sortable">
<text>余额</text>
<text class="sort-icon">↕</text>
</view>
<view class="col col-ops"><text>操作</text></view>
</view>
<!-- 表格内容 -->
<view class="table-body">
<!-- 加载中 -->
<view v-if="loading" class="empty-state-row">
<text class="empty-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="fetchError !== ''" class="empty-state-row">
<text class="empty-text">{{ fetchError }}</text>
<button class="btn small" @click="fetchUsers">重试</button>
</view>
<!-- 无数据 -->
<view v-else-if="pagedList.length === 0" class="empty-state-row">
<text class="empty-text">暂无用户数据</text>
</view>
<view v-for="user in pagedList" :key="user.id" class="table-row"
:style="{ zIndex: activeDropdownId === user.id ? 1000 : 1 }"
>
<view class="col col-check"><checkbox :checked="user.checked" /></view>
<view class="col col-expand"><text class="expand-arrow"></text></view>
<view class="col col-id"><text>{{ user.id }}</text></view>
<view class="col col-avatar">
<image class="avatar-img" :src="user.avatar" mode="aspectFill" />
</view>
<view class="col col-name">
<text class="name-text">{{ user.nickname }}</text>
</view>
<view class="col col-member">
<text :class="user.isMember === '是' ? 'status-yes' : 'status-no'">{{ user.isMember }}</text>
</view>
<view class="col col-level"><text>{{ user.level }}</text></view>
<view class="col col-group"><text>{{ user.group }}</text></view>
<view class="col col-spread"><text>{{ user.spreadLevel }}</text></view>
<view class="col col-phone"><text>{{ user.phone }}</text></view>
<view class="col col-type"><text>{{ user.userType }}</text></view>
<view class="col col-balance"><text>{{ user.balance }}</text></view>
<view class="col col-ops">
<text class="op-link" @click.stop="onDetail(user)">详情</text>
<view class="op-divider">|</view>
<view class="more-hover-container"
@mouseover="activeDropdownId = user.id"
@mouseleave="activeDropdownId = null"
@click.stop="activeDropdownId = (activeDropdownId === user.id ? null : user.id)"
>
<view class="more-trigger pointer">
<text class="op-link">更多</text>
<text class="arrow"></text>
</view>
<view class="dropdown-list-box" v-if="activeDropdownId === user.id">
<view class="dropdown-arrow-top"></view>
<view class="dropdown-menu-list">
<text class="menu-item" @click.stop="uni.showToast({title:'修改余额', icon:'none'})">修改余额</text>
<text class="menu-item" @click.stop="uni.showToast({title:'修改积分', icon:'none'})">修改积分</text>
<text class="menu-item" @click.stop="uni.showToast({title:'赠送会员', icon:'none'})">赠送会员</text>
<text class="menu-item" @click.stop="uni.showToast({title:'设置分组', icon:'none'})">设置分组</text>
<text class="menu-item" @click.stop="uni.showToast({title:'设置标签', icon:'none'})">设置标签</text>
<text class="menu-item" @click.stop="uni.showToast({title:'修改上级推广人', icon:'none'})">修改上级推广人</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 分页 -->
<CommonPagination
v-if="true"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
import { supabase } from '@/components/supadb/aksupainstance.uts'
const activeTab = ref(0)
const tabs = ['全部', '微信公众号', '微信小程序', 'H5', 'PC', 'APP']
const isAllChecked = ref(false)
const activeDropdownId = ref<string | null>(null)
// ========== 用户展示行类型 ==========
type UserRow = {
id: string
avatar: string
nickname: string
isMember: string
level: string
group: string
spreadLevel: string
phone: string
userType: string
balance: string
checked: boolean
}
// ========== 数据状态 ==========
const loading = ref(false)
const fetchError = ref('')
const userList = ref<UserRow[]>([])
// ========== 字段映射函数 ==========
function formatLevelDisplay(levelNum: number | null): string {
if (levelNum == null || levelNum <= 1) return '无'
return 'Lv.' + levelNum
}
function formatUserType(regSource: string | null, role: string | null): string {
if (regSource === 'web') return 'PC/H5'
if (regSource === 'mobile') return 'APP'
if (regSource === 'wechat' || regSource === 'weixin') return '微信'
if (role === 'admin') return '管理员'
if (role === 'merchant') return '商家'
if (role === 'delivery') return '配送员'
return '用户'
}
function mapDbRow(row: UTSJSONObject): UserRow {
const id = row.getString('id') ?? ''
const avatarUrl = row.getString('avatar_url') ?? ''
const username = row.getString('username') ?? ''
const emailStr = row.getString('email') ?? ''
// 昵称:优先 username兜底取邮箱前缀再兜底 '—'
let nickname = '—'
if (username !== '') {
nickname = username
} else if (emailStr !== '') {
const atIdx = emailStr.indexOf('@')
nickname = atIdx > 0 ? emailStr.substring(0, atIdx) : emailStr
}
const phone = row.getString('phone') ?? ''
const regSource = row.getString('registration_source')
const role = row.getString('role')
const levelNum = row.getNumber('user_level')
const avatarFinal = avatarUrl !== '' ? avatarUrl : '/static/logo.png'
return {
id: id,
avatar: avatarFinal,
nickname: nickname,
isMember: '—', // ak_users 无付费会员字段
level: formatLevelDisplay(levelNum),
group: '—', // ak_users 无分组字段
spreadLevel: '—', // ak_users 无分销等级字段
phone: phone,
userType: formatUserType(regSource, role),
balance: '—', // ak_users 无余额字段total_spent 为消费额,语义不同)
checked: false
} as UserRow
}
// ========== 数据请求 ==========
// ⚠️ 前置条件:需在 Supabase 添加 admin 读取全部用户的 RLS 策略:
// CREATE POLICY "ak_users_admin_read_all" ON public.ak_users
// FOR SELECT TO authenticated
// USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' OR auth.uid() = id);
//
// ✅ 使用 limit+offset URL 参数代替 Range 头,彻底避免 PostgREST 416 问题
const fetchUsers = async (page: number = 1, ps: number = pageSize.value) => {
if (loading.value) return // 防止并发重复请求
loading.value = true
fetchError.value = ''
try {
// offset 注入到 filter 字符串PostgREST 将其识别为 SQL OFFSET不发 Range 头
const offset = (page - 1) * ps
const offsetFilter = offset > 0 ? `offset=${offset}` : null
const res = await supabase.select(
'ak_users',
offsetFilter,
{
columns: 'id, username, email, phone, avatar_url, role, registration_source, user_level, created_at',
limit: ps,
order: 'created_at.desc',
count: 'exact' // 触发 Prefer: count=exact → 响应带 Content-Range 总行数
}
)
if (res.status >= 200 && res.status < 300 && res.data != null) {
userList.value = (res.data as UTSJSONObject[]).map((row: UTSJSONObject): UserRow => mapDbRow(row))
// 从 Content-Range 响应头解析真实总行数格式0-14/total
let totalCount = 0
const hdrs = res.headers
if (hdrs != null) {
let cr: string | null = null
if (typeof (hdrs as any).get === 'function') {
cr = (hdrs as any).get('content-range') as string | null
}
if (cr == null) {
cr = (hdrs as UTSJSONObject)['content-range'] as string | null
}
if (cr != null) {
const m = /\/(\d+)$/.exec(cr)
if (m != null) totalCount = parseInt(m[1] ?? '0')
}
}
// content-range 解析失败时:以 offset + 当前页条数 作保守兜底
if (totalCount === 0) {
totalCount = offset + (Array.isArray(res.data) ? (res.data as UTSJSONObject[]).length : 0)
}
total.value = totalCount
} else {
fetchError.value = '加载用户列表失败,请检查网络或 RLS 权限配置'
}
} catch (e) {
fetchError.value = '请求异常,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(() => {
fetchUsers(1, pageSize.value)
})
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(15)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = ref(0) // 来自服务端 content-range 真实总行数
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => userList.value) // 服务端已按页返回,直接展示当前页数据
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => {
if (p < 1 || p > totalPage.value) return
currentPage.value = p
fetchUsers(p, pageSize.value)
}
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
fetchUsers(1, pageSize.value)
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) {
currentPage.value = p
fetchUsers(p, pageSize.value)
}
}
// ========== END PAGINATION STATE ==========
function onSearch() {
uni.showToast({ title: '搜索中...', icon: 'none' })
}
function onReset() {
uni.showToast({ title: '已重置', icon: 'none' })
}
function onAddUser() {
uni.showToast({ title: '添加用户', icon: 'none' })
}
function onDetail(user: any) {
uni.showToast({ title: '查看用户: ' + user.id, icon: 'none' })
}
</script>
<style scoped lang="scss">
.admin-page {
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
/* 筛选卡片 */
.filter-card {
padding: var(--admin-card-padding);
}
.filter-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 24px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
}
.label {
font-size: 14px;
color: #333;
width: 70px;
}
.input-group {
display: flex;
flex-direction: row;
border: 1px solid #d9d9d9;
border-radius: 2px;
height: 32px;
width: 260px;
}
.compact-select {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
border-right: 1px solid #d9d9d9;
background: #fafafa;
text { font-size: 14px; color: #666; }
.arrow { font-size: 10px; margin-left: 4px; color: #bfbfbf; }
}
.filter-input {
flex: 1;
height: 30px;
padding: 0 12px;
font-size: 14px;
}
.filter-select {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
border: 1px solid #d9d9d9;
border-radius: 2px;
height: 32px;
width: 220px;
padding: 0 12px;
background: #fff;
}
.select-placeholder { font-size: 14px; color: #bfbfbf; }
.arrow { font-size: 10px; color: #bfbfbf; }
.filter-btns {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.btn {
height: 32px;
padding: 0 16px;
font-size: 14px;
border-radius: 2px;
border: 1px solid #d9d9d9;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin: 0;
}
.btn.primary {
background: #2f54eb;
border-color: #2f54eb;
color: #fff;
}
.btn.ghost {
color: #2f54eb;
border-color: #2f54eb;
background: #fff;
}
.btn.small {
height: 28px;
padding: 0 12px;
font-size: 13px;
}
.expand-btn {
font-size: 14px;
color: #2f54eb;
cursor: pointer;
}
/* 内容卡片 */
.content-card {
padding: 0;
overflow: visible; /* 必须 visible 以显示下拉菜单 */
}
/* Tabs */
.tabs-row {
display: flex;
flex-direction: row;
padding: 0 24px;
border-bottom: 1px solid #f0f0f0;
}
.tab-item {
padding: 16px 20px;
cursor: pointer;
position: relative;
text { font-size: 15px; color: #666; }
&.active {
text { color: #2f54eb; font-weight: 500; }
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: #2f54eb;
}
}
}
/* 操作栏 */
.action-bar {
padding: 16px 24px;
display: flex;
flex-direction: row;
gap: 12px;
}
/* 表格 */
.table-container {
padding: 0 24px 24px;
overflow: visible;
}
.table-header {
display: flex;
flex-direction: row;
background: #f8faff;
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
}
.table-row {
display: flex;
flex-direction: row;
border-bottom: 1px solid #f0f0f0;
padding: 16px 0;
align-items: center;
position: relative;
overflow: visible;
&:hover {
background: #fafafa;
}
}
.col {
padding: 0 8px;
display: flex;
align-items: center;
font-size: 14px;
color: #333;
}
.col-check { width: 40px; justify-content: center; }
.col-expand { width: 30px; justify-content: center; }
.col-id { width: 80px; }
.col-avatar { width: 80px; justify-content: center; }
.col-name { width: 140px; }
.col-member { width: 90px; justify-content: center; }
.col-level { width: 90px; justify-content: center; }
.col-group { width: 110px; justify-content: center; }
.col-spread { width: 110px; justify-content: center; }
.col-phone { width: 130px; }
.col-type { width: 90px; }
.col-balance { width: 110px; }
.col-ops {
display: flex;
flex-direction: row;
width: 130px;
justify-content: flex-end;
padding-right: 16px;
align-items: center;
}
.more-hover-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 32px;
min-width: 46px; /* 增加点击和悬停范围 */
padding: 0 4px;
cursor: pointer;
}
.more-trigger {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
pointer-events: none; /* 确保事件冒泡到 container */
.arrow { font-size: 10px; color: #2f54eb; margin-left: 2px; }
}
.dropdown-list-box {
position: absolute;
top: 30px;
right: -5px; /* 稍微向左移动一点 */
width: 140px; /* 增加宽度以容纳长文字 */
padding-top: 10px; /* 增加缓冲区防止鼠标移出 */
z-index: 9999;
}
.dropdown-arrow-top {
position: absolute;
top: 2px;
right: 25px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #fff;
}
.dropdown-menu-list {
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
display: flex;
flex-direction: column;
padding: 6px 0;
}
.menu-item {
padding: 10px 16px;
font-size: 14px;
color: #606266;
text-align: center;
line-height: 1.4;
&:hover {
background-color: #f5f7fa;
color: #2f54eb;
}
}
.danger-item {
&:hover {
color: #ff4d4f !important;
}
}
.pointer { cursor: pointer; }
.table-header .col {
color: #5c5c5c;
font-weight: 500;
}
.sort-icon {
font-size: 12px;
margin-left: 4px;
color: #bfbfbf;
}
.expand-arrow {
color: #bfbfbf;
font-size: 18px;
}
.avatar-img {
width: 40px;
height: 40px;
border-radius: 4px;
background: #f5f5f5;
}
.name-text {
font-weight: 400;
}
.status-yes { color: #52c41a; }
.status-no { color: #ff4d4f; }
.op-link {
color: #2f54eb;
cursor: pointer;
font-size: 14px;
}
.op-divider {
color: #e8e8e8;
font-size: 12px;
margin: 0 8px;
}
/* 分页区域已迁至 CommonPagination 组件 */
.empty-state-row {
padding: 40px 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
width: 100%;
}
.empty-text {
font-size: 14px;
color: #999;
text-align: center;
}
</style>