feat: 完成 #6 用户管理列表筛选与详情页
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export function getUsers(params) {
|
||||
return request({
|
||||
url: '/api/admin/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getUserById(id) {
|
||||
return request({
|
||||
url: `/api/admin/users/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
+171
-2
@@ -1,8 +1,177 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>用户详情</span>
|
||||
<div class="header-row">
|
||||
<span>用户详情</span>
|
||||
<el-button @click="goBack">返回列表</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="开发中,下一步对接 /api/admin/users/:id" />
|
||||
|
||||
<el-skeleton v-if="loading" :rows="8" animated />
|
||||
<el-empty v-else-if="!detail.id" description="用户不存在或已删除" />
|
||||
<div v-else class="detail-wrap">
|
||||
<el-descriptions title="基本信息" :column="2" border>
|
||||
<el-descriptions-item label="用户ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="昵称">{{ detail.nickname || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="头像">
|
||||
<el-avatar :src="detail.avatar || detail.avatar_url" :size="36" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">{{ genderLabel(detail.gender) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ maskPhone(detail.phone || detail.mobile) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="OpenID">{{ detail.open_id || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-descriptions title="所属小程序" :column="2" border>
|
||||
<el-descriptions-item label="小程序ID">
|
||||
{{ detail.mini_program_id || detail.mini_program?.id || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="小程序名称">
|
||||
{{ detail.mini_program_name || detail.mini_program?.name || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-descriptions title="会员信息" :column="2" border>
|
||||
<el-descriptions-item label="会员状态">
|
||||
<el-tag :type="memberInfo.is_member ? 'success' : 'info'">
|
||||
{{ memberInfo.is_member ? '会员' : '非会员' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="套餐类型">
|
||||
{{ memberInfo.package_type || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">
|
||||
{{ formatDateTime(memberInfo.expires_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="注册时间">
|
||||
{{ formatDateTime(detail.created_at || detail.registered_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-card class="stats-card" shadow="never">
|
||||
<template #header>
|
||||
<span>数据统计</span>
|
||||
</template>
|
||||
<el-empty v-if="statsEntries.length === 0" description="暂无统计数据" />
|
||||
<el-row v-else :gutter="12">
|
||||
<el-col
|
||||
v-for="item in statsEntries"
|
||||
:key="item.key"
|
||||
:xs="12"
|
||||
:sm="8"
|
||||
:md="6"
|
||||
>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-value">{{ item.value }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getUserById } from '../../api/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = reactive({})
|
||||
const memberInfo = reactive({
|
||||
is_member: false,
|
||||
package_type: '',
|
||||
expires_at: ''
|
||||
})
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const maskPhone = (value) => {
|
||||
if (!value || String(value).length < 7) return value || '-'
|
||||
const phone = String(value)
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
}
|
||||
|
||||
const genderLabel = (gender) => {
|
||||
if (gender === 1 || gender === '1') return '男'
|
||||
if (gender === 2 || gender === '2') return '女'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const statsEntries = computed(() => {
|
||||
const stats = detail.stats || {}
|
||||
return Object.keys(stats).map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
value: stats[key]
|
||||
}))
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserById(route.params.id)
|
||||
const payload = res.data || {}
|
||||
const user = payload.user || payload
|
||||
Object.assign(detail, user)
|
||||
|
||||
const membership = payload.membership || user.membership || {}
|
||||
memberInfo.is_member = !!(user.is_member || membership.is_member)
|
||||
memberInfo.package_type = membership.package_type || membership.plan_name || ''
|
||||
memberInfo.expires_at = membership.expires_at || membership.ends_at || ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/users')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #f6f8fa;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin-top: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
|
||||
+196
-2
@@ -1,8 +1,202 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>用户管理</span>
|
||||
<div class="header-row">
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索昵称/手机号"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.mini_program_id"
|
||||
placeholder="全部小程序"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in miniProgramOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="query.is_member"
|
||||
placeholder="全部会员状态"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="会员" :value="1" />
|
||||
<el-option label="非会员" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="开发中,下一步对接 /api/admin/users" />
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="头像" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :size="36" :src="row.avatar || row.avatar_url">
|
||||
{{ (row.nickname || 'U').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" min-width="140" />
|
||||
<el-table-column label="手机号" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ maskPhone(row.phone || row.mobile) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属小程序" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.mini_program_name || row.mini_program?.name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会员状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getMemberTagType(row.is_member)">
|
||||
{{ row.is_member ? '会员' : '非会员' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="注册时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at || row.registered_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="goDetail(row.id)">查看详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getMiniPrograms } from '../../api/miniProgram'
|
||||
import { getUsers } from '../../api/user'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const miniProgramOptions = ref([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
mini_program_id: undefined,
|
||||
keyword: '',
|
||||
is_member: undefined
|
||||
})
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const maskPhone = (value) => {
|
||||
if (!value || String(value).length < 7) return value || '-'
|
||||
const phone = String(value)
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
|
||||
}
|
||||
|
||||
const getMemberTagType = (isMember) => {
|
||||
return isMember ? 'success' : 'info'
|
||||
}
|
||||
|
||||
const loadMiniProgramOptions = async () => {
|
||||
const res = await getMiniPrograms({
|
||||
page: 1,
|
||||
page_size: 200
|
||||
})
|
||||
const payload = res.data || {}
|
||||
miniProgramOptions.value = payload.list || []
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUsers(query)
|
||||
const payload = res.data || {}
|
||||
list.value = payload.list || []
|
||||
total.value = payload.total || 0
|
||||
query.page = payload.page || query.page
|
||||
query.page_size = payload.page_size || query.page_size
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
query.keyword = ''
|
||||
query.mini_program_id = undefined
|
||||
query.is_member = undefined
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
router.push(`/users/${id}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadMiniProgramOptions(), loadData()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user