Merge remote-tracking branch 'origin/huangzhenbao-admin'

This commit is contained in:
not-like-juvenile
2026-03-18 17:14:05 +08:00
676 changed files with 25158 additions and 46646 deletions

View File

@@ -1,81 +0,0 @@
<template>
<view class="page-container">
<view class="page-header">
<text class="page-title">卡密会员</text>
<text class="page-subtitle">Component: UserGradeCard</text>
</view>
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">页面占位</text>
<text class="placeholder-desc">该功能模块正在开发中</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
// TODO: 实现 卡密会员 的具体功能
const loading = ref<boolean>(false)
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.page-subtitle {
display: block;
font-size: 14px;
color: #999;
}
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
.placeholder-desc {
display: block;
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
</style>

View File

@@ -1,81 +0,0 @@
<template>
<view class="page-container">
<view class="page-header">
<text class="page-title">会员记录</text>
<text class="page-subtitle">Component: UserGradeRecord</text>
</view>
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">页面占位</text>
<text class="placeholder-desc">该功能模块正在开发中</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
// TODO: 实现 会员记录 的具体功能
const loading = ref<boolean>(false)
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.page-subtitle {
display: block;
font-size: 14px;
color: #999;
}
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
.placeholder-desc {
display: block;
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
</style>

View File

@@ -1,81 +0,0 @@
<template>
<view class="page-container">
<view class="page-header">
<text class="page-title">会员权益</text>
<text class="page-subtitle">Component: UserGradeRight</text>
</view>
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">页面占位</text>
<text class="placeholder-desc">该功能模块正在开发中</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
// TODO: 实现 会员权益 的具体功能
const loading = ref<boolean>(false)
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.page-subtitle {
display: block;
font-size: 14px;
color: #999;
}
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
.placeholder-desc {
display: block;
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
</style>

View File

@@ -1,81 +0,0 @@
<template>
<view class="page-container">
<view class="page-header">
<text class="page-title">会员类型</text>
<text class="page-subtitle">Component: UserGradeType</text>
</view>
<view class="page-content">
<view class="placeholder-card">
<text class="placeholder-title">页面占位</text>
<text class="placeholder-desc">该功能模块正在开发中</text>
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
// TODO: 实现 会员类型 的具体功能
const loading = ref<boolean>(false)
</script>
<style scoped lang="scss">
.page-container {
padding: 20px;
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
display: block;
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.page-subtitle {
display: block;
font-size: 14px;
color: #999;
}
.page-content {
background: #fff;
border-radius: 4px;
padding: 24px;
}
.placeholder-card {
text-align: center;
padding: 60px 20px;
}
.placeholder-title {
display: block;
font-size: 18px;
font-weight: 600;
color: #666;
margin-bottom: 12px;
}
.placeholder-desc {
display: block;
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.placeholder-info {
display: block;
font-size: 12px;
color: #1890ff;
}
</style>

View File

@@ -3,12 +3,7 @@
<view class="content-card">
<!-- 操作按钮行 -->
<view class="action-bar">
<view class="filter-item">
<input class="filter-input" v-model="searchText" placeholder="搜索分组名称/备注" @confirm="handleSearch" />
</view>
<button class="btn primary small" @click="onAddGroup">添加分组</button>
<button class="btn ghost small" @click="handleSearch">查询</button>
<button class="btn ghost small" @click="handleReset">重置</button>
</view>
<!-- 分组列表表格 -->
@@ -16,48 +11,41 @@
<!-- 表头 -->
<view class="table-header">
<view class="col col-id"><text>ID</text></view>
<view class="col col-name"><text>分组名称</text></view>
<view class="col col-remark"><text>备注</text></view>
<view class="col col-status"><text>状态</text></view>
<view class="col col-name"><text>分组</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<!-- 表格内容 -->
<view class="table-body">
<view v-if="loading" class="table-loading"><text>加载中...</text></view>
<view v-else-if="groupList.length === 0" class="table-empty"><text>暂无数据</text></view>
<view v-else v-for="(group, index) in groupList" :key="group.id" class="table-row">
<view v-for="group in groupList" :key="group.id" class="table-row">
<view class="col col-id"><text>{{ group.id }}</text></view>
<view class="col col-name"><text>{{ group.name }}</text></view>
<view class="col col-remark"><text>{{ group.remark || '-' }}</text></view>
<view class="col col-status">
<view :class="['switch-box', group.status === 1 ? 'active' : '']" @click="onToggleStatus(index)">
<view class="switch-dot"></view>
</view>
</view>
<view class="col col-ops">
<text class="op-link" @click="onEditGroup(group)">修改</text>
<view class="op-divider">|</view>
<text class="op-link text-danger" @click="onDeleteGroup(group.id)">删除</text>
<text class="op-link" @click="onDeleteGroup(group)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页区域 -->
<view class="pagination-row">
<text class="total-text">共 {{ total }} 条</text>
<view class="page-btns">
<view :class="['page-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"><text></text></view>
<view class="page-btn active"><text>{{ page }}</text></view>
<view :class="['page-btn', page >= totalPages ? 'disabled' : '']" @click="nextPage"><text></text></view>
</view>
<view class="page-jump">
<text>前往</text>
<input class="jump-input" v-model="jumpPage" @confirm="goToJumpPage" />
<text>页</text>
</view>
</view>
<!-- 分页 -->
<CommonPagination
v-if="groupList.length > 0"
:total="groupList.length"
: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 = val }"
@jump-page="handleJumpPage"
/>
</view>
<!-- 添加/修改分组弹窗 -->
@@ -77,26 +65,9 @@
class="form-input"
v-model="formData.name"
placeholder="请输入分组名称"
autofocus
/>
</view>
<view class="form-item" style="margin-top: 20px;">
<view class="label-box">
<text class="label">备注说明:</text>
</view>
<textarea
class="form-textarea"
v-model="formData.remark"
placeholder="请输入备注说明"
/>
</view>
<view class="form-item" style="margin-top: 20px;">
<view class="label-box">
<text class="label">状态:</text>
</view>
<view :class="['switch-box', formData.status === 1 ? 'active' : '']" @click="formData.status = (formData.status === 1 ? 0 : 1)">
<view class="switch-dot"></view>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn ghost" @click="closeModal">取消</button>
@@ -108,51 +79,16 @@
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import {
fetchAdminUserGroupPage,
saveAdminUserGroup,
deleteAdminUserGroup,
setAdminUserGroupStatus,
type AdminUserGroup
} from '@/services/admin/userGroupService.uts'
import { ref, reactive, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const searchText = ref('')
const total = ref(0)
const groupList = ref<AdminUserGroup[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
const pages = Math.ceil(total.value / pageSize.value)
return pages <= 0 ? 1 : pages
})
const loadGroupList = async () => {
loading.value = true
try {
const res = await fetchAdminUserGroupPage(page.value, pageSize.value, {
search: searchText.value || null,
status: null,
includeDeleted: false
})
groupList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(() => {
loadGroupList()
})
// 分组数据
const groupList = ref([
{ id: 251, name: 'A类客户' },
{ id: 252, name: 'B类客户' },
{ id: 253, name: 'C类客户' },
{ id: 254, name: 'D类客户' }
])
// 弹窗状态
const showModal = ref(false)
@@ -161,101 +97,35 @@ const modalTitle = computed(() => isEdit.value ? '修改分组' : '添加分组'
// 表单数据
const formData = reactive({
id: '',
name: '',
remark: '',
status: 1
id: 0,
name: ''
})
// 搜索与重置
function handleSearch() {
page.value = 1
loadGroupList()
}
function handleReset() {
searchText.value = ''
page.value = 1
loadGroupList()
}
// 分页控制
function prevPage() {
if (page.value > 1) {
page.value--
loadGroupList()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadGroupList()
}
}
function goToJumpPage() {
const targetPage = parseInt(jumpPage.value)
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages.value) {
page.value = targetPage
loadGroupList()
jumpPage.value = ''
} else {
uni.showToast({ title: '请输入有效的页码', icon: 'none' })
}
}
// 状态切换
async function onToggleStatus(index: number) {
const group = groupList.value[index]
const targetStatus = group.status === 1 ? 0 : 1
try {
const ok = await setAdminUserGroupStatus(group.id, targetStatus)
if (ok) {
group.status = targetStatus
uni.showToast({ title: '状态更新成功' })
}
} catch (e) {
uni.showToast({ title: '状态更新失败', icon: 'none' })
}
}
// 添加分组
function onAddGroup() {
isEdit.value = false
formData.id = ''
formData.id = 0
formData.name = ''
formData.remark = ''
formData.status = 1
showModal.value = true
}
// 修改分组
function onEditGroup(group: AdminUserGroup) {
function onEditGroup(group: any) {
isEdit.value = true
formData.id = group.id
formData.name = group.name
formData.remark = group.remark || ''
formData.status = group.status
showModal.value = true
}
// 删除分组
function onDeleteGroup(id: string) {
function onDeleteGroup(group: any) {
uni.showModal({
title: '提示',
content: '确定要删除该分组吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
try {
const ok = await deleteAdminUserGroup(id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadGroupList()
}
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
groupList.value = groupList.value.filter(item => item.id !== group.id)
uni.showToast({ title: '删除成功', icon: 'success' })
}
}
})
@@ -267,66 +137,79 @@ function closeModal() {
}
// 提交表单
async function submitForm() {
function submitForm() {
if (!formData.name) {
uni.showToast({ title: '请输入分组名称', icon: 'none' })
return
}
try {
const resId = await saveAdminUserGroup({
id: isEdit.value ? formData.id : null,
name: formData.name,
remark: formData.remark,
status: formData.status
})
if (resId != null) {
uni.showToast({ title: isEdit.value ? '修改成功' : '添加成功' })
closeModal()
loadGroupList()
if (isEdit.value) {
// 模拟修改
const index = groupList.value.findIndex(item => item.id === formData.id)
if (index > -1) {
groupList.value[index].name = formData.name
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
// 模拟添加
const newId = groupList.value.length > 0 ? Math.max(...groupList.value.map(g => g.id)) + 1 : 1
groupList.value.push({
id: newId,
name: formData.name
})
uni.showToast({ title: '添加成功', icon: 'success' })
}
closeModal()
}
// 分页适配状态
const currentPage = ref(1)
const pageSize = ref(15)
let jumpPageInput = ''
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 totalPage = computed(() => Math.max(1, Math.ceil(groupList.value.length / pageSize.value)))
const visiblePages = computed(() => {
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) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
</script>
<style scoped lang="scss">
.user-group-page {
padding: 16px;
background-color: #f0f2f5;
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
.content-card {
background: #fff;
border-radius: 4px;
padding: 24px;
padding: var(--admin-card-padding);
}
.action-bar {
margin-bottom: 20px;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
}
.filter-input {
width: 200px;
height: 32px;
padding: 0 12px;
border: 1px solid #d9d9d9;
border-radius: 2px;
font-size: 14px;
background-color: #fff;
}
/* 按钮样式 */
@@ -420,28 +303,6 @@ async function submitForm() {
justify-content: flex-end;
}
.col-remark {
flex: 1;
}
.col-status {
width: 120px;
display: flex;
justify-content: center;
}
.table-loading,
.table-empty {
padding: 24px;
text-align: center;
color: #999;
font-size: 14px;
}
.text-danger {
color: #ff4d4f;
}
.op-link {
color: #1890ff;
cursor: pointer;
@@ -459,112 +320,7 @@ async function submitForm() {
opacity: 0.5;
}
/* Switch 开关 */
.switch-box {
width: 44px;
height: 22px;
background-color: #dcdfe6;
border-radius: 11px;
position: relative;
transition: background-color 0.3s;
cursor: pointer;
}
.switch-box.active {
background-color: #1890ff;
}
.switch-dot {
width: 18px;
height: 18px;
background-color: #fff;
border-radius: 9px;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.3s;
}
.switch-box.active .switch-dot {
transform: translateX(22px);
}
/* 分页样式 */
.pagination-row {
margin-top: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
color: #666;
font-size: 14px;
}
.total-text {
margin-right: 16px;
}
.page-size-selector {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 8px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-right: 16px;
.arrow {
font-size: 10px;
margin-left: 8px;
color: #999;
}
}
.page-btns {
display: flex;
flex-direction: row;
margin-right: 16px;
}
.page-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-right: 8px;
cursor: pointer;
&.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
&.disabled {
color: #ccc;
cursor: not-allowed;
}
}
.page-jump {
display: flex;
flex-direction: row;
align-items: center;
.jump-input {
width: 48px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin: 0 8px;
text-align: center;
font-size: 14px;
}
}
/* 分页区域已迁至 CommonPagination 组件 */
/* 弹窗样式 */
.modal-mask {
@@ -652,20 +408,6 @@ async function submitForm() {
}
}
.form-textarea {
flex: 1;
height: 80px;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 2px;
font-size: 14px;
&:focus {
border-color: #40a9ff;
outline: none;
}
}
.modal-footer {
padding: 10px 16px;
border-top: 1px solid #f0f0f0;

View File

@@ -1,14 +1,9 @@
<template>
<view class="user-label-page">
<view class="content-card">
<!-- 搜索与操作栏 -->
<!-- 操作按钮行 -->
<view class="action-bar">
<view class="filter-item">
<input class="filter-input" v-model="searchText" placeholder="搜索标签名称/备注" @confirm="handleSearch" />
</view>
<button class="btn primary small" @click="onAddLabel">添加标签</button>
<button class="btn ghost small" @click="handleSearch">查询</button>
<button class="btn ghost small" @click="handleReset">重置</button>
</view>
<!-- 标签列表表格 -->
@@ -17,49 +12,40 @@
<view class="table-header">
<view class="col col-id"><text>ID</text></view>
<view class="col col-name"><text>标签名称</text></view>
<view class="col col-remark"><text>备注</text></view>
<view class="col col-status"><text>状态</text></view>
<view class="col col-ops"><text>操作</text></view>
</view>
<!-- 表格内容 -->
<view class="table-body">
<view v-if="loading" class="table-loading"><text>加载中...</text></view>
<view v-else-if="labelList.length === 0" class="table-empty"><text>暂无数据</text></view>
<view v-else v-for="(label, index) in labelList" :key="label.id" class="table-row">
<view v-for="label in labelList" :key="label.id" class="table-row">
<view class="col col-id"><text>{{ label.id }}</text></view>
<view class="col col-name">
<text :style="{ color: label.color || '#666' }">{{ label.name }}</text>
</view>
<view class="col col-remark"><text>{{ label.remark || '-' }}</text></view>
<view class="col col-status">
<view :class="['switch-box', label.status === 1 ? 'active' : '']" @click="onToggleStatus(index)">
<view class="switch-dot"></view>
</view>
</view>
<view class="col col-name"><text>{{ label.name }}</text></view>
<view class="col col-ops">
<text class="op-link" @click="onEditLabel(label)">修改</text>
<view class="op-divider">|</view>
<text class="op-link text-danger" @click="onDeleteLabel(label.id)">删除</text>
<text class="op-link" @click="onDeleteLabel(label)">删除</text>
</view>
</view>
</view>
</view>
<!-- 分页区域 -->
<view class="pagination-row">
<text class="total-text">共 {{ total }} 条</text>
<view class="page-btns">
<view :class="['page-btn', page <= 1 ? 'disabled' : '']" @click="prevPage"><text></text></view>
<view class="page-btn active"><text>{{ page }}</text></view>
<view :class="['page-btn', page >= totalPages ? 'disabled' : '']" @click="nextPage"><text></text></view>
</view>
<view class="page-jump">
<text>前往</text>
<input class="jump-input" v-model="jumpPage" @confirm="goToJumpPage" />
<text>页</text>
</view>
</view>
<!-- 分页 -->
<CommonPagination
v-if="labelList.length > 0"
:total="labelList.length"
: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 = val }"
@jump-page="handleJumpPage"
/>
</view>
<!-- 添加/修改标签弹窗 -->
@@ -79,36 +65,9 @@
class="form-input"
v-model="formData.name"
placeholder="请输入标签名称"
autofocus
/>
</view>
<view class="form-item" style="margin-top: 20px;">
<view class="label-box">
<text class="label">标签颜色:</text>
</view>
<input
class="form-input"
v-model="formData.color"
placeholder="例如: #1890ff"
/>
</view>
<view class="form-item" style="margin-top: 20px;">
<view class="label-box">
<text class="label">备注说明:</text>
</view>
<textarea
class="form-textarea"
v-model="formData.remark"
placeholder="请输入备注说明"
/>
</view>
<view class="form-item" style="margin-top: 20px;">
<view class="label-box">
<text class="label">状态:</text>
</view>
<view :class="['switch-box', formData.status === 1 ? 'active' : '']" @click="formData.status = (formData.status === 1 ? 0 : 1)">
<view class="switch-dot"></view>
</view>
</view>
</view>
<view class="modal-footer">
<button class="btn ghost" @click="closeModal">取消</button>
@@ -120,86 +79,16 @@
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted } from 'vue'
import {
fetchAdminUserLabelPage,
saveAdminUserLabel,
deleteAdminUserLabel,
setAdminUserLabelStatus,
type AdminUserLabel
} from '@/services/admin/userLabelService.uts'
import { ref, reactive, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const searchText = ref('')
const total = ref(0)
const labelList = ref<AdminUserLabel[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
const pages = Math.ceil(total.value / pageSize.value)
return pages <= 0 ? 1 : pages
})
const loadLabelList = async () => {
loading.value = true
try {
const res = await fetchAdminUserLabelPage(page.value, pageSize.value, {
search: searchText.value || null,
status: null,
includeDeleted: false
})
labelList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(() => {
loadLabelList()
})
function handleSearch() {
page.value = 1
loadLabelList()
}
function handleReset() {
searchText.value = ''
page.value = 1
loadLabelList()
}
function prevPage() {
if (page.value > 1) {
page.value--
loadLabelList()
}
}
function nextPage() {
if (page.value < totalPages.value) {
page.value++
loadLabelList()
}
}
function goToJumpPage() {
const targetPage = parseInt(jumpPage.value)
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages.value) {
page.value = targetPage
loadLabelList()
jumpPage.value = ''
} else {
uni.showToast({ title: '请输入有效的页码', icon: 'none' })
}
}
// 标签数据
const labelList = ref([
{ id: 1, name: '新客户' },
{ id: 2, name: '老客户' },
{ id: 3, name: '活跃客户' },
{ id: 4, name: '潜在客户' }
])
// 弹窗状态
const showModal = ref(false)
@@ -208,138 +97,117 @@ const modalTitle = computed(() => isEdit.value ? '修改标签' : '添加标签'
// 表单数据
const formData = reactive({
id: '',
name: '',
color: '',
remark: '',
status: 1
id: 0,
name: ''
})
// 添加标签
function onAddLabel() {
isEdit.value = false
formData.id = ''
formData.id = 0
formData.name = ''
formData.color = ''
formData.remark = ''
formData.status = 1
showModal.value = true
}
function onEditLabel(label: AdminUserLabel) {
// 修改标签
function onEditLabel(label: any) {
isEdit.value = true
formData.id = label.id
formData.name = label.name
formData.color = label.color ?? ''
formData.remark = label.remark ?? ''
formData.status = label.status
showModal.value = true
}
function onDeleteLabel(id: string) {
// 删除标签
function onDeleteLabel(label: any) {
uni.showModal({
title: '提示',
content: '确定要删除该标签吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
try {
const ok = await deleteAdminUserLabel(id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadLabelList()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '删除失败', icon: 'none' })
}
labelList.value = labelList.value.filter(item => item.id !== label.id)
uni.showToast({ title: '删除成功', icon: 'success' })
}
}
})
}
async function onToggleStatus(index: number) {
const item = labelList.value[index]
const targetStatus = item.status === 1 ? 0 : 1
try {
const ok = await setAdminUserLabelStatus(item.id, targetStatus)
if (ok) {
item.status = targetStatus
uni.showToast({ title: '状态更新成功' })
} else {
uni.showToast({ title: '状态更新失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '状态更新失败', icon: 'none' })
}
}
// 关闭弹窗
function closeModal() {
showModal.value = false
}
async function submitForm() {
// 提交表单
function submitForm() {
if (!formData.name) {
uni.showToast({ title: '请输入标签名称', icon: 'none' })
return
}
try {
const resId = await saveAdminUserLabel({
id: isEdit.value ? formData.id : null,
name: formData.name,
color: formData.color || null,
remark: formData.remark || null,
status: formData.status
})
if (resId != null) {
uni.showToast({ title: isEdit.value ? '修改成功' : '添加成功' })
closeModal()
loadLabelList()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
if (isEdit.value) {
const index = labelList.value.findIndex(item => item.id === formData.id)
if (index > -1) {
labelList.value[index].name = formData.name
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
const newId = labelList.value.length > 0 ? Math.max(...labelList.value.map(g => g.id)) + 1 : 1
labelList.value.push({
id: newId,
name: formData.name
})
uni.showToast({ title: '添加成功', icon: 'success' })
}
closeModal()
}
// 分页适配状态
const currentPage = ref(1)
const pageSize = ref(15)
let jumpPageInput = ''
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 totalPage = computed(() => Math.max(1, Math.ceil(labelList.value.length / pageSize.value)))
const visiblePages = computed(() => {
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) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
</script>
<style scoped lang="scss">
.user-label-page {
padding: 16px;
background-color: #f0f2f5;
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
.content-card {
background: #fff;
border-radius: 4px;
padding: 24px;
padding: var(--admin-card-padding);
}
.action-bar {
margin-bottom: 20px;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.filter-item {
display: flex;
flex-direction: row;
align-items: center;
}
.filter-input {
width: 200px;
height: 32px;
padding: 0 12px;
border: 1px solid #d9d9d9;
border-radius: 2px;
font-size: 14px;
background-color: #fff;
}
/* 按钮样式 */
@@ -426,16 +294,6 @@ async function submitForm() {
flex: 1;
}
.col-remark {
flex: 1.5;
}
.col-status {
width: 120px;
display: flex;
justify-content: center;
}
.col-ops {
width: 150px;
display: flex;
@@ -443,14 +301,6 @@ async function submitForm() {
justify-content: flex-end;
}
.table-loading,
.table-empty {
padding: 24px;
text-align: center;
color: #999;
font-size: 14px;
}
.op-link {
color: #1890ff;
cursor: pointer;
@@ -468,99 +318,7 @@ async function submitForm() {
opacity: 0.5;
}
.text-danger {
color: #ff4d4f;
}
/* Switch 开关 */
.switch-box {
width: 44px;
height: 22px;
background-color: #dcdfe6;
border-radius: 11px;
position: relative;
transition: background-color 0.3s;
cursor: pointer;
}
.switch-box.active {
background-color: #1890ff;
}
.switch-dot {
width: 18px;
height: 18px;
background-color: #fff;
border-radius: 9px;
position: absolute;
top: 2px;
left: 2px;
transition: transform 0.3s;
}
.switch-box.active .switch-dot {
transform: translateX(22px);
}
/* 分页样式 */
.pagination-row {
margin-top: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
color: #666;
font-size: 14px;
}
.total-text {
margin-right: 16px;
}
.page-btns {
display: flex;
flex-direction: row;
margin-right: 16px;
}
.page-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-right: 8px;
cursor: pointer;
&.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
}
&.disabled {
color: #ccc;
cursor: not-allowed;
}
}
.page-jump {
display: flex;
flex-direction: row;
align-items: center;
.jump-input {
width: 48px;
height: 32px;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin: 0 8px;
text-align: center;
font-size: 14px;
}
}
/* 分页区域已迁至 CommonPagination 组件 */
/* 弹窗样式 */
.modal-mask {
@@ -648,20 +406,6 @@ async function submitForm() {
}
}
.form-textarea {
flex: 1;
height: 80px;
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 2px;
font-size: 14px;
&:focus {
border-color: #40a9ff;
outline: none;
}
}
.modal-footer {
padding: 10px 16px;
border-top: 1px solid #f0f0f0;

View File

@@ -73,24 +73,22 @@
</view>
<!-- 分页 -->
<view class="pagination-footer">
<view class="page-total">
<text class="total-txt">共 {{ total }} 条</text>
</view>
<view class="page-select">
<text class="page-val">15条/页 ▼</text>
</view>
<view class="page-btns">
<text class="p-btn disabled"><</text>
<text class="p-btn active">1</text>
<text class="p-btn">></text>
</view>
<view class="page-jump">
<text class="jump-txt">前往</text>
<input class="jump-input" placeholder="1" />
<text class="jump-txt">页</text>
</view>
</view>
<CommonPagination
v-if="total > 0"
: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 = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
@@ -157,13 +155,8 @@
</template>
<script setup lang="uts">
import { ref, reactive, onMounted, computed } from 'vue'
import {
fetchAdminUserLevelPage,
saveAdminUserLevel,
deleteAdminUserLevel,
setAdminUserLevelVisible
} from '@/services/admin/userLevelService.uts'
import { ref, reactive, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
interface LevelItem {
id: number
@@ -178,76 +171,48 @@ interface LevelItem {
}
const filterName = ref('')
const total = ref(0)
const levelList = ref<AdminUserLevel[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(15)
const jumpPage = ref('')
const totalPages = computed((): number => {
if (pageSize.value <= 0) return 1
const pages = Math.ceil(total.value / pageSize.value)
return pages <= 0 ? 1 : pages
})
const loadLevelList = async () => {
loading.value = true
try {
const res = await fetchAdminUserLevelPage(page.value, pageSize.value, {
search: filterName.value || null,
isVisible: null, // 全部
status: null
})
levelList.value = res.items
total.value = res.total
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
onMounted(() => {
loadLevelList()
})
const handleQuery = () => {
page.value = 1
loadLevelList()
}
const prevPage = () => {
if (page.value > 1) {
page.value--
loadLevelList()
}
}
const nextPage = () => {
if (page.value < totalPages.value) {
page.value++
loadLevelList()
}
}
const goToJumpPage = () => {
const targetPage = parseInt(jumpPage.value)
if (!isNaN(targetPage) && targetPage >= 1 && targetPage <= totalPages.value) {
page.value = targetPage
loadLevelList()
jumpPage.value = ''
} else {
uni.showToast({ title: '请输入有效的页码', icon: 'none' })
}
}
const total = ref(5)
const levelList = ref<LevelItem[]>([
{ id: 1, name: 'V1', level: 1, iconBg: '#fdf6ec', iconSymbol: '👑', bgGradient: 'linear-gradient(to bottom right, #f5e6d3, #e8d5bc)', discount: 99, experience: 500, isShow: true },
{ id: 2, name: 'V2', level: 2, iconBg: '#ecf5ff', iconSymbol: '💎', bgGradient: 'linear-gradient(to bottom right, #d3e9f5, #bcd9e8)', discount: 97, experience: 1000, isShow: true },
{ id: 3, name: 'V3', level: 3, iconBg: '#f4f4f5', iconSymbol: '⭐', bgGradient: 'linear-gradient(to bottom right, #e3e3e3, #cbcbcb)', discount: 95, experience: 3000, isShow: true },
{ id: 4, name: 'V4', level: 4, iconBg: '#fef0f0', iconSymbol: '👑', bgGradient: 'linear-gradient(to bottom right, #f5dfd3, #e8c6bc)', discount: 93, experience: 8000, isShow: true },
{ id: 5, name: 'V5', level: 5, iconBg: '#f0f9eb', iconSymbol: '💠', bgGradient: 'linear-gradient(to bottom right, #d3e1f5, #bccce8)', discount: 70, experience: 15000, isShow: true }
])
const showDrawer = ref(false)
const isClosing = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
// 分页适配状态
const currentPage = ref(1)
const pageSize = ref(15)
let jumpPageInput = ''
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 totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const visiblePages = computed(() => {
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) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
const isEdit = ref(false)
const form = reactive({
name: '',
level: 1,
@@ -256,11 +221,11 @@ const form = reactive({
isShow: true
})
const handleQuery = () => { console.log('Querying...') }
const handleAdd = () => {
isEdit.value = false
editingId.value = null
form.name = ''
form.level = 1
form.level = levelList.value.length + 1
form.discount = 100
form.experience = 0
form.isShow = true
@@ -268,99 +233,55 @@ const handleAdd = () => {
isClosing.value = false
}
const handleEdit = (item: AdminUserLevel) => {
const handleEdit = (item: LevelItem) => {
isEdit.value = true
editingId.value = item.id
form.name = item.name
form.level = item.level_weight
form.discount = item.discount_percent
form.experience = item.min_experience
form.isShow = item.is_visible
form.level = item.level
form.discount = item.discount
form.experience = item.experience
form.isShow = item.isShow
showDrawer.value = true
isClosing.value = false
}
const handleDelete = (id: string) => {
uni.showModal({
title: '确认删除',
content: '确定要删除该用户等级吗?',
success: async (res) => {
if (res.confirm) {
const ok = await deleteAdminUserLevel(id)
if (ok) {
uni.showToast({ title: '删除成功' })
loadLevelList()
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
const closeDrawer = () => {
isClosing.value = true
setTimeout(() => {
showDrawer.value = false
isClosing.value = false
}, 300)
}
const toggleShow = async (index: number) => {
const item = levelList.value[index]
const targetVisible = !item.is_visible
const ok = await setAdminUserLevelVisible(item.id, targetVisible)
if (ok) {
item.is_visible = targetVisible
} else {
uni.showToast({ title: '修改失败', icon: 'none' })
}
const toggleShow = (index: number) => {
levelList.value[index].isShow = !levelList.value[index].isShow
}
const handleSave = async () => {
if (!form.name) {
uni.showToast({ title: '请输入等级名称', icon: 'none' })
return
}
try {
const resId = await saveAdminUserLevel({
id: editingId.value,
name: form.name,
level_weight: form.level,
min_experience: form.experience,
discount_percent: form.discount,
is_visible: form.isShow,
status: 1 // 默认启用
})
if (resId != null) {
uni.showToast({ title: '保存成功' })
closeDrawer()
loadLevelList()
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '保存出错', icon: 'none' })
}
const handleSave = () => {
console.log('Saving...', form)
closeDrawer()
}
</script>
<style scoped lang="scss">
.admin-user-level {
background-color: #f0f2f5;
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
padding: 24px;
}
.border-shadow {
background-color: #fff;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
.content-body {
display: flex;
flex-direction: column;
gap: 20px;
gap: var(--admin-section-gap);
}
/* 过滤栏 */
.filter-card {
padding: 24px;
padding: var(--admin-card-padding);
display: flex;
flex-direction: row;
align-items: center;
@@ -508,34 +429,7 @@ const handleSave = async () => {
.op-split { color: #e8eaec; margin: 0 5px; }
.text-danger { color: #ed4014; }
/* 分页 */
.pagination-footer {
padding: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.total-txt { font-size: 14px; color: #606266; }
.page-val { font-size: 14px; color: #606266; border: 1px solid #dcdfe6; padding: 4px 10px; border-radius: 4px; }
.page-btns { display: flex; flex-direction: row; gap: 8px; }
.p-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.p-btn.active { background-color: #2d8cf0; border-color: #2d8cf0; color: #fff; }
.p-btn.disabled { color: #c0c4cc; background-color: #f5f7fa; }
.page-jump { display: flex; flex-direction: row; align-items: center; gap: 8px; }
.jump-txt { font-size: 14px; color: #606266; }
.jump-input { width: 40px; height: 32px; border: 1px solid #dcdfe6; text-align: center; border-radius: 4px; font-size: 14px; }
/* 分页区域已迁至 CommonPagination 组件 */
/* Drawer Styles */
.drawer-mask {

View File

@@ -88,7 +88,7 @@
<!-- 表格内容 -->
<view class="table-body">
<view v-for="user in userList" :key="user.id" class="table-row"
<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>
@@ -138,19 +138,34 @@
</view>
</view>
<!-- 分页区域 (模拟) -->
<view class="pagination">
<text class="page-info">共 80834 条数据</text>
</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 } from 'vue'
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const activeTab = ref(0)
// ========== MOCK DATA START ==========
const tabs = ['全部', '微信公众号', '微信小程序', 'H5', 'PC', 'APP']
const isAllChecked = ref(false)
const activeDropdownId = ref<string | null>(null)
@@ -164,8 +179,51 @@ const userList = ref([
{ id: '75289', avatar: '/static/logo.png', nickname: '小二上酒', isMember: '否', level: '无', group: 'A类客户', spreadLevel: '', phone: '', userType: '公众号', balance: '100000.00', checked: false },
{ id: '75257', avatar: '/static/logo.png', nickname: '5+7', isMember: '是', level: '无', group: 'A类客户', spreadLevel: '', phone: '', userType: '公众号', balance: '100000.00', checked: false },
{ id: '75226', avatar: '/static/logo.png', nickname: '慢步前行', isMember: '是', level: '无', group: 'A类客户', spreadLevel: '', phone: '', userType: '公众号', balance: '100000.00', checked: false },
{ id: '75211', avatar: '/static/logo.png', nickname: '难得糊涂', isMember: '否', level: '无', group: 'A类客户', spreadLevel: '', phone: '', userType: '公众号', balance: '100000.00', checked: false }
{ id: '75211', avatar: '/static/logo.png', nickname: '难得糊涂', isMember: '否', level: '无', group: 'A类客户', spreadLevel: '', phone: '', userType: '公众号', balance: '100000.00', checked: false },
{ id: '75100', avatar: '/static/logo.png', nickname: '山河远阔', isMember: '否', level: '无', group: '无', spreadLevel: '', phone: '138****5566', userType: '小程序', balance: '9800.00', checked: false },
{ id: '74988', avatar: '/static/logo.png', nickname: '野草菓芙', isMember: '是', level: '金等', group: 'B类客户', spreadLevel: '一级', phone: '155****7788', userType: '公众号', balance: '25600.00', checked: false },
{ id: '74833', avatar: '/static/logo.png', nickname: '星花雨月', isMember: '否', level: '无', group: '无', spreadLevel: '', phone: '', userType: 'H5', balance: '320.00', checked: false },
{ id: '74701', avatar: '/static/logo.png', nickname: '天道酬勤', isMember: '是', level: '银等', group: 'A类客户', spreadLevel: '二级', phone: '186****3344', userType: '小程序', balance: '7700.00', checked: false },
{ id: '74590', avatar: '/static/logo.png', nickname: '南风知劲草', isMember: '否', level: '无', group: '无', spreadLevel: '', phone: '177****1122', userType: 'APP', balance: '150.00', checked: false },
{ id: '74422', avatar: '/static/logo.png', nickname: '大漠孤烟', isMember: '是', level: '金等', group: 'A类客户', spreadLevel: '一级', phone: '139****9900', userType: '公众号', balance: '88800.00', checked: false },
{ id: '74310', avatar: '/static/logo.png', nickname: '小桥流水', isMember: '否', level: '无', group: 'B类客户', spreadLevel: '', phone: '', userType: 'PC', balance: '600.00', checked: false },
{ id: '74198', avatar: '/static/logo.png', nickname: '日暗香残', isMember: '是', level: '银等', group: '无', spreadLevel: '一级', phone: '135****6677', userType: '小程序', balance: '4300.00', checked: false },
{ id: '74056', avatar: '/static/logo.png', nickname: '梦里花开', isMember: '否', level: '无', group: '无', spreadLevel: '', phone: '', userType: '公众号', balance: '200.00', checked: false },
{ id: '73900', avatar: '/static/logo.png', nickname: '春风十里', isMember: '是', level: '金等', group: 'A类客户', spreadLevel: '二级', phone: '151****4455', userType: 'APP', balance: '56000.00', checked: false }
])
// ========== MOCK DATA END ==========
// ========== 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 = computed(() => userList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return userList.value.slice(start, start + pageSize.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) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
function onSearch() {
uni.showToast({ title: '搜索中...', icon: 'none' })
@@ -185,8 +243,14 @@ function onDetail(user: any) {
</script>
<style scoped lang="scss">
.admin-page {
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
/* 筛选卡片 */
.filter-card {
padding: var(--admin-card-padding);
}
.filter-row {
@@ -507,16 +571,5 @@ function onDetail(user: any) {
margin: 0 8px;
}
.pagination {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: flex-start;
}
.page-info {
font-size: 14px;
color: #999;
}
/* 分页区域已迁至 CommonPagination 组件 */
</style>

View File

@@ -1,9 +1,10 @@
<template>
<view class="statistic-page">
<!-- 筛选栏 -->
<view class="filter-card">
<view class="filter-item">
<text class="filter-label">用户渠道:</text>
<view class="admin-page">
<view class="admin-sections">
<!-- 筛选栏 -->
<view class="admin-card filter-card">
<view class="filter-item">
<text class="filter-label">用户渠道:</text>
<view class="select-box">
<text class="select-text">全部</text>
<text class="select-arrow">▼</text>
@@ -11,13 +12,11 @@
</view>
<view class="filter-item">
<text class="filter-label">选择月份</text>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<text class="filter-label">选择时间</text>
<view class="date-picker-box">
<text class="date-icon">📅</text>
<text class="date-text">2026/01/04 - 2026/02/02</text>
</view>
</view>
<view class="filter-btns">
@@ -26,14 +25,14 @@
</view>
</view>
<!-- 用户概况卡片区 -->
<view class="section-card">
<!-- 用户概况卡片区 (使用 6-2-1 响应式网格) -->
<view class="admin-card section-card">
<view class="section-header">
<text class="section-title">用户概况</text>
<text class="info-icon"></text>
<text class="info-icon"></text>
</view>
<view class="kpi-row">
<view class="kpi-grid-6">
<view class="kpi-card" v-for="item in kpiData" :key="item.title">
<view class="kpi-icon-box" :style="{ backgroundColor: item.bg }">
<text class="kpi-icon">{{ item.icon }}</text>
@@ -43,7 +42,10 @@
<text class="kpi-value">{{ item.value }}</text>
<view class="kpi-meta">
<text class="meta-label">环比增长:</text>
<text class="meta-value" :class="item.trend">{{ item.percent }} {{ item.trend === 'up' ? '▲' : '▼' }}</text>
<text class="meta-value" :class="item.trend">
{{ item.percent }}
<text class="trend-icon">{{ item.trend === 'up' ? '▲' : '▼' }}</text>
</text>
</view>
</view>
</view>
@@ -65,227 +67,66 @@
</view>
</view>
<!-- 地域分布与性别比例 -->
<view class="analysis-row">
<view class="map-col">
<AnalyticsUserMapTable />
</view>
<view class="gender-col">
<AnalyticsUserGenderSection :startDate="startDate" :endDate="endDate" />
<!-- 地域分布与性别比例 -->
<view class="analysis-row">
<view class="map-col">
<AnalyticsUserMapTable />
</view>
<view class="gender-col">
<AnalyticsUserGenderSection />
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import AnalyticsMultiLineChart from '@/components/analytics/AnalyticsMultiLineChart.uvue'
import AnalyticsUserMapTable from '@/components/analytics/AnalyticsUserMapTable.uvue'
import AnalyticsUserGenderSection from '@/components/analytics/AnalyticsUserGenderSection.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
import { getCurrentUser } from '@/utils/store.uts'
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
type KpiCard = {
title: string
value: string
percent: string
trend: string
icon: string
bg: string
}
const kpiData = [
{ title: '累计用户', value: '80887', percent: '0.79%', trend: 'up', icon: '👥', bg: '#efebff' },
{ title: '访客数', value: '1076', percent: '11.65%', trend: 'down', icon: '👤', bg: '#e8f4ff' },
{ title: '浏览量', value: '8843', percent: '12.09%', trend: 'down', icon: '📁', bg: '#e6fff1' },
{ title: '新增用户数', value: '635', percent: '14.65%', trend: 'down', icon: '👤', bg: '#fff7e6' },
{ title: '成交用户数', value: '122', percent: '0.81%', trend: 'down', icon: '👥', bg: '#f2f0ff' },
{ title: '付费会员数', value: '76', percent: '13.63%', trend: 'down', icon: '💎', bg: '#f2f0ff' }
]
type LineSeries = {
name: string
color: string
data: Array<number>
}
const selectedMonth = ref<string>('')
const startDate = ref<string>('')
const endDate = ref<string>('')
const dateRangeText = computed((): string => {
if (!startDate.value || !endDate.value) return '请选择月份'
const fmt = (s: string): string => s.replace(/-/g, '/')
return `${fmt(startDate.value)} - ${fmt(endDate.value)}`
})
const kpiData = ref<Array<KpiCard>>([
{ title: '累计用户', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#f3e8ff' },
{ title: '活跃用户', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#e0f2fe' },
{ title: '新增用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#ffedd5' },
{ title: '下单用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#dcfce7' },
{ title: '支付用户数', value: '-', percent: '-', trend: 'up', icon: '👤', bg: '#f3e8ff' },
{ title: '复购率', value: '-', percent: '-', trend: 'up', icon: '💎', bg: '#f3e8ff' }
])
const chartData = ref<{ x: Array<string>; series: Array<LineSeries> }>({
x: [],
const chartData = {
x: ['01-04', '01-05', '01-06', '01-07', '01-08', '01-09', '01-10', '01-11', '01-12', '01-13', '01-14', '01-15', '01-16', '01-17', '01-18', '01-19', '01-20', '01-21', '01-22', '01-23', '01-24', '01-25', '01-26', '01-27', '01-28', '01-29', '01-30', '01-31', '02-01', '02-02'],
series: [
{ name: '新增用户数', color: '#1890ff', data: [] },
{ name: '活跃用户', color: '#52c41a', data: [] }
{ name: '新增用户数', color: '#1890ff', data: [40, 30, 25, 30, 22, 10, 20, 32, 28, 15, 8, 12, 18, 22, 15, 12, 25, 30, 28, 25, 35, 20, 18, 22, 20, 15, 10, 8, 15, 38] },
{ name: '访客数', color: '#52c41a', data: [70, 75, 65, 55, 65, 50, 45, 35, 50, 68, 72, 65, 50, 48, 55, 65, 75, 62, 58, 85, 70, 55, 48, 58, 65, 72, 68, 60, 45, 50] },
{ name: '浏览量', color: '#fa8c16', data: [520, 500, 420, 280, 580, 180, 220, 100, 180, 450, 500, 400, 320, 340, 150, 280, 450, 320, 440, 460, 320, 260, 320, 280, 380, 400, 320, 330, 250, 300] },
{ name: '成交用户数', color: '#722ed1', data: [15, 12, 10, 8, 18, 5, 8, 4, 6, 12, 15, 10, 8, 9, 4, 10, 12, 8, 10, 12, 8, 6, 10, 8, 12, 14, 10, 8, 5, 8] },
{ name: '新增付费用户数', color: '#f5222d', data: [5, 4, 3, 2, 6, 1, 2, 1, 2, 4, 5, 3, 2, 3, 1, 3, 4, 2, 3, 4, 2, 2, 3, 2, 4, 5, 3, 2, 1, 3] }
]
})
function safeNumber(v: any): number {
const n = Number(v)
return isFinite(n) ? n : 0
}
function formatInt(v: any): string {
const n = Math.round(safeNumber(v))
return n.toString()
}
function formatPct(v: any): string {
const n = safeNumber(v)
const s = n.toFixed(2) + '%'
return s
}
function trendOf(pct: number): string {
return pct >= 0 ? 'up' : 'down'
}
async function loadData() {
try {
await ensureSupabaseReady()
if (!startDate.value || !endDate.value) {
uni.showToast({ title: '请先选择月份', icon: 'none' })
return
}
const startStr = startDate.value
const endStr = endDate.value
const p = new UTSJSONObject()
p.set('p_start_date', startStr)
p.set('p_end_date', endStr)
const kRes: any = await supa.rpc('rpc_analytics_user_kpis', p)
const row = Array.isArray(kRes.data) && kRes.data.length > 0 ? kRes.data[0] : (kRes.data || {})
const totalUsers = safeNumber(row.total_users)
const totalGrowth = safeNumber(row.user_growth)
const newUsers = safeNumber(row.new_users)
const newGrowth = safeNumber(row.new_user_growth)
const activeUsers = safeNumber(row.active_users)
const activeGrowth = safeNumber(row.active_growth)
const orderingUsers = safeNumber(row.ordering_users)
const orderingGrowth = safeNumber(row.ordering_growth)
const paidUsers = safeNumber(row.paid_users)
const paidGrowth = safeNumber(row.paid_growth)
const repurchaseRate = safeNumber(row.repurchase_rate)
const repurchaseGrowth = safeNumber(row.repurchase_growth)
kpiData.value = [
{ title: '累计用户', value: formatInt(totalUsers), percent: formatPct(totalGrowth), trend: trendOf(totalGrowth), icon: '👤', bg: '#f3e8ff' },
{ title: '活跃用户', value: formatInt(activeUsers), percent: formatPct(activeGrowth), trend: trendOf(activeGrowth), icon: '👤', bg: '#e0f2fe' },
{ title: '新增用户数', value: formatInt(newUsers), percent: formatPct(newGrowth), trend: trendOf(newGrowth), icon: '👤', bg: '#ffedd5' },
{ title: '下单用户数', value: formatInt(orderingUsers), percent: formatPct(orderingGrowth), trend: trendOf(orderingGrowth), icon: '👤', bg: '#dcfce7' },
{ title: '支付用户数', value: formatInt(paidUsers), percent: formatPct(paidGrowth), trend: trendOf(paidGrowth), icon: '👤', bg: '#f3e8ff' },
{ title: '复购率', value: formatPct(repurchaseRate), percent: formatPct(repurchaseGrowth), trend: trendOf(repurchaseGrowth), icon: '💎', bg: '#f3e8ff' }
]
const tRes: any = await supa.rpc('rpc_analytics_user_growth_trend', p)
const rows: Array<any> = Array.isArray(tRes.data) ? (tRes.data as Array<any>) : []
const x: Array<string> = []
const newArr: Array<number> = []
const activeArr: Array<number> = []
for (let i = 0; i < rows.length; i++) {
const d = `${rows[i].date}`
x.push(d.slice(5))
newArr.push(Number(rows[i].new_users) || 0)
activeArr.push(Number(rows[i].active_users) || 0)
}
chartData.value = {
x,
series: [
{ name: '新增用户数', color: '#1890ff', data: newArr },
{ name: '活跃用户', color: '#52c41a', data: activeArr }
]
}
} catch (e) {
console.error('admin user statistic loadData failed', e)
uni.showToast({ title: '数据加载失败', icon: 'none', duration: 2000 })
}
}
function onSearch() {
loadData()
uni.showToast({ title: '搜索中...' })
}
function onExport() {
uni.showToast({ title: '导出功能待接入', icon: 'none' })
uni.showToast({ title: '导出中...' })
}
function onDateRangeApply(range: { start: string; end: string }) {
startDate.value = range.start
endDate.value = range.end
loadData()
}
function onDateRangeClear() {
startDate.value = ''
endDate.value = ''
kpiData.value.forEach(item => {
item.value = '-'
item.percent = '-'
})
chartData.value.x = []
chartData.value.series.forEach(s => s.data = [])
}
// 初始化默认月份为当前月
function initCurrentMonth() {
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
const firstDay = new Date(year, month - 1, 1)
const lastDay = new Date(year, month, 0)
startDate.value = firstDay.toISOString().slice(0, 10)
endDate.value = lastDay.toISOString().slice(0, 10)
}
onLoad(async () => {
if (!ensureAnalyticsLogin({ toastTitle: '请先登录以访问用户统计' })) return
const profile = await getCurrentUser()
const role = profile?.role
if (!role || !['admin', 'analytics'].includes(role)) {
uni.showToast({ title: '权限不足', icon: 'none' })
setTimeout(() => uni.switchTab({ url: '/pages/mall/consumer/index' }), 800)
return
}
initCurrentMonth()
loadData()
})
</script>
<style scoped>
.statistic-page {
padding: 16px;
background-color: #f0f2f5;
.admin-page {
/* 使用 Layout 的背景和内边距 */
min-height: 100vh;
}
.filter-card {
background-color: #fff;
border-radius: 4px;
padding: 16px 24px;
display: flex;
flex-direction: row;
align-items: center;
gap: 32px;
margin-bottom: 16px;
padding: var(--admin-card-padding);
}
.filter-item {
@@ -357,116 +198,77 @@ onLoad(async () => {
}
.section-card {
background-color: #fff;
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.section-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
margin-bottom: 24px;
}
.section-title {
font-size: 16px;
font-weight: 500;
color: #262626;
font-weight: 600;
color: #333;
}
.info-icon {
margin-left: 6px;
color: #999;
font-size: 14px;
color: #bfbfbf;
}
.kpi-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 16px;
margin-bottom: 40px;
}
@media (max-width: 1200px) {
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.kpi-row {
grid-template-columns: 1fr;
}
}
/* kpi-row 已废弃,采用全局 kpi-grid-6 */
.kpi-card {
min-width: 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 6px;
padding: 16px 8px;
min-width: 0;
}
.kpi-icon-box {
width: 32px;
height: 32px;
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
margin-right: 12px;
flex-shrink: 0;
}
.kpi-icon { font-size: 14px; }
.kpi-icon { font-size: 20px; }
.kpi-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.kpi-label {
font-size: 12px;
color: #8c8c8c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kpi-value {
font-size: 20px;
font-weight: 500;
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kpi-label { font-size: 13px; color: #666; margin-bottom: 4px; }
.kpi-value { font-size: 24px; font-weight: 700; color: #333; line-height: 1.2; margin-bottom: 4px; }
.kpi-meta {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
font-size: 12px;
min-width: 0;
flex-wrap: wrap;
}
.meta-label {
color: #8c8c8c;
flex: 0 0 auto;
}
.meta-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-value.up { color: #ff4d4f; }
.meta-label { font-size: 12px; color: #999; }
.meta-value { font-size: 12px; font-weight: 500; }
.meta-value.up { color: #f5222d; }
.meta-value.down { color: #52c41a; }
.trend-icon {
font-size: 10px;
margin-left: 2px;
}
.chart-container {
border-top: 1px solid #f0f0f0;
padding-top: 24px;
@@ -486,18 +288,39 @@ onLoad(async () => {
}
.analysis-row {
display: flex;
flex-direction: row;
gap: 16px;
margin-top: 16px;
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--admin-section-gap);
width: 100%;
}
.map-col {
flex: 7;
.map-col, .gender-col {
min-width: 0;
display: flex; /* 让子元素可以正常撑开 */
flex-direction: column;
}
.gender-col {
flex: 3;
@media (max-width: 1199.98px) {
.analysis-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.filter-card {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.filter-item {
width: 100%;
}
.select-box, .date-picker-box {
flex: 1;
}
}
/* @media (max-width: 1199.98px) 已将 analysis-row 移出,与 filter-card 逻辑解耦 */
</style>