用户反馈: 操作列只显示一个文字按钮+⋯ 不方便, 期望按按钮数量智能显示 - 过滤 when 后 ≤3 个: 全部显示文字按钮, 不显示 ⋯ - 过滤 when 后 ≥4 个: 前 2 个常驻文字按钮 + ⋯ (收纳剩余) - 默认 visibleCount=2 (可配置) TableActions API 调整 (W17.6 → W17.6.1): - 原来: :primary=[A] + :more=[B,C,D] (人为主次划分) - 现在: :actions=[A,B,C,D] (按数组顺序自动前 2 显 + ⋯) 16 文件改动 (1 组件 + 15 view 调用方式): - 15 view 合并 :primary/:more → :actions - 3 按钮页 (10 个): 现在全部显示文字按钮无 ⋯, 体验更直接 - 4/5 按钮页 (5 个): 前 2 显 + ⋯, 比之前详情+⋯更直观
360 lines
12 KiB
Vue
360 lines
12 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* F006 用户管理 (前台用户列表) - W17 设计规范 v2 完整版
|
|
*
|
|
* 按 ui-preview-v2.html 模板: 9 列 + 6 搜索 + 4 操作 + 状态 tab
|
|
*/
|
|
import { onMounted, ref, reactive, computed } from 'vue'
|
|
import { http, httpGet, httpPost } from '@/utils/request'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { Plus } from '@element-plus/icons-vue'
|
|
import DataTable from '@/components/DataTable/DataTable.vue'
|
|
import PageHeader from '@/components/PageHeader/PageHeader.vue'
|
|
import SearchBar from '@/components/SearchBar/SearchBar.vue'
|
|
import ListTabs from '@/components/ListTabs/ListTabs.vue'
|
|
import UserFormDialog from './UserFormDialog.vue'
|
|
import TableActions from '@/components/TableActions/TableActions.vue'
|
|
|
|
interface FrontUser {
|
|
id: string
|
|
nickName: string
|
|
mobile: string
|
|
header?: string | null
|
|
isEnterprise: number
|
|
enterpriseName?: string | null
|
|
enterpriseId?: string | null
|
|
accountBalance: number
|
|
enabled: number
|
|
createDate: number
|
|
isDel: number
|
|
raw: any
|
|
}
|
|
|
|
const list = ref<FrontUser[]>([])
|
|
const total = ref(0)
|
|
const enabledStats = reactive({ normal: 0, disabled: 0 })
|
|
const loading = ref(false)
|
|
const activeTab = ref('all')
|
|
|
|
// 表单弹窗状态
|
|
const dialogVisible = ref(false)
|
|
const editingUserId = ref<string | number | null>(null)
|
|
const exporting = ref(false)
|
|
|
|
const query = reactive({
|
|
page: 1,
|
|
limit: 10,
|
|
userId: '',
|
|
nickName: '',
|
|
mobile: '',
|
|
enterpriseName: '',
|
|
isEnterprise: '' as '' | '0' | '1',
|
|
enabled: '' as '' | '0' | '1'
|
|
})
|
|
|
|
const columns: any[] = [
|
|
{ type: 'selection', width: 50, fixed: 'left' },
|
|
{ prop: 'id', label: '用户ID', width: 100 },
|
|
{ prop: 'nickName', label: '用户昵称', minWidth: 160 },
|
|
{ prop: 'mobile', label: '手机号', width: 140 },
|
|
{
|
|
prop: 'isEnterprise',
|
|
label: '是否企业认证',
|
|
width: 140,
|
|
formatter: (row: FrontUser) => row.isEnterprise === 1 ? '是' : '否'
|
|
},
|
|
{
|
|
prop: 'enterpriseName',
|
|
label: '关联企业',
|
|
minWidth: 200,
|
|
formatter: (row: FrontUser) => row.enterpriseName || '--'
|
|
},
|
|
{
|
|
prop: 'accountBalance',
|
|
label: '平台账户余额',
|
|
width: 140,
|
|
align: 'right',
|
|
formatter: (row: FrontUser) => Number(row.accountBalance || 0).toFixed(2)
|
|
},
|
|
{
|
|
prop: 'enabled',
|
|
label: '禁用标识',
|
|
width: 110,
|
|
slot: 'enabled'
|
|
},
|
|
{
|
|
prop: 'createDate',
|
|
label: '注册时间',
|
|
width: 180,
|
|
formatter: (row: FrontUser) => fmtDate(row.createDate)
|
|
},
|
|
{ label: '操作', width: 140, slot: 'action', fixed: 'right' }
|
|
]
|
|
|
|
function fmtDate(ts: number | null | undefined) {
|
|
if (!ts) return '--'
|
|
const d = new Date(Number(ts))
|
|
if (isNaN(d.getTime())) return '--'
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
|
}
|
|
|
|
/**
|
|
* 构建除 enabled (tab) 外的搜索参数 (用于 stats 查询)
|
|
* stats 必须与 tab 解耦, 只反映当前搜索条件下的全量计数
|
|
*/
|
|
function buildSearchParams() {
|
|
const params: any = {}
|
|
if (query.userId) params.id = query.userId
|
|
if (query.nickName) params.nickName = query.nickName
|
|
if (query.mobile) params.mobile = query.mobile
|
|
if (query.enterpriseName) params.enterpriseName = query.enterpriseName
|
|
if (query.isEnterprise !== '') params.isEnterprise = Number(query.isEnterprise)
|
|
return params
|
|
}
|
|
|
|
async function loadList() {
|
|
loading.value = true
|
|
try {
|
|
const params: any = { page: query.page, limit: query.limit, ...buildSearchParams() }
|
|
if (query.enabled !== '') params.enabled = Number(query.enabled)
|
|
const resp: any = await httpGet('/user/user/page', params)
|
|
if (resp && Array.isArray(resp.list)) {
|
|
list.value = resp.list.map((it: any) => ({
|
|
id: it.id,
|
|
nickName: it.nickName || '--',
|
|
mobile: it.mobile || '--',
|
|
header: it.header || null,
|
|
isEnterprise: Number(it.isEnterprise ?? 0),
|
|
enterpriseName: it.enterpriseName || null,
|
|
enterpriseId: it.enterpriseId || null,
|
|
accountBalance: Number(it.accountBalance ?? 0),
|
|
enabled: Number(it.enabled ?? 0),
|
|
createDate: Number(it.createDate ?? 0),
|
|
isDel: Number(it.isDel ?? 0),
|
|
raw: it
|
|
}))
|
|
total.value = Number(resp.total || 0)
|
|
} else {
|
|
list.value = []
|
|
total.value = 0
|
|
}
|
|
} catch (e: any) {
|
|
list.value = []
|
|
total.value = 0
|
|
ElMessage.error(e?.msg || e?.message || '加载失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 加载 stats (normal/disabled 全量计数)
|
|
* 与 tab 无关, 只跟搜索条件 (userId/nickName/mobile/isEnterprise/enterpriseName) 关联
|
|
* 通过 limit=1 + page=1 拿到 total (后端只 count 不返列表, 开销可忽略)
|
|
*/
|
|
async function loadStats() {
|
|
try {
|
|
const baseParams = buildSearchParams()
|
|
const [respNormal, respDisabled] = await Promise.all([
|
|
httpGet('/user/user/page', { ...baseParams, enabled: 0, page: 1, limit: 1 }),
|
|
httpGet('/user/user/page', { ...baseParams, enabled: 1, page: 1, limit: 1 })
|
|
])
|
|
enabledStats.normal = Number(respNormal?.total ?? 0)
|
|
enabledStats.disabled = Number(respDisabled?.total ?? 0)
|
|
} catch {
|
|
enabledStats.normal = 0
|
|
enabledStats.disabled = 0
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
await Promise.all([loadList(), loadStats()])
|
|
}
|
|
|
|
const tabItems = computed(() => [
|
|
// 全部 = 正常 + 已禁用 (跨 tab 一致, 与搜索条件关联)
|
|
{ key: 'all', label: '全部', count: enabledStats.normal + enabledStats.disabled },
|
|
{ key: 'normal', label: '正常', count: enabledStats.normal },
|
|
{ key: 'disabled', label: '已禁用', count: enabledStats.disabled }
|
|
])
|
|
|
|
function onTabChange(key: string) {
|
|
// 后端 enabled 语义: 0=正常 1=禁用
|
|
if (key === 'all') query.enabled = ''
|
|
else if (key === 'normal') query.enabled = '0'
|
|
else if (key === 'disabled') query.enabled = '1'
|
|
query.page = 1
|
|
loadList() // 切 tab 只重载列表, stats 已与 tab 解耦
|
|
}
|
|
|
|
function onPageChange(p: number) { query.page = p; load() }
|
|
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
|
|
function onSearch() { query.page = 1; load() }
|
|
function onReset() {
|
|
query.userId = ''; query.nickName = ''; query.mobile = ''
|
|
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
|
|
activeTab.value = 'all'
|
|
query.page = 1
|
|
load()
|
|
}
|
|
|
|
function onEdit(row: FrontUser) {
|
|
editingUserId.value = row.id
|
|
dialogVisible.value = true
|
|
}
|
|
|
|
function onCreate() {
|
|
editingUserId.value = null
|
|
dialogVisible.value = true
|
|
}
|
|
|
|
function onDialogSuccess() {
|
|
load()
|
|
}
|
|
|
|
/**
|
|
* 导出当前搜索条件下的用户列表为 Excel
|
|
* 后端: GET /user/user/export (返 Excel 文件流, Content-Type: application/vnd.ms-excel)
|
|
* 走 http 实例 (自动带 Bearer token), responseType: 'blob' 接二进制流
|
|
*/
|
|
async function exportExcel() {
|
|
exporting.value = true
|
|
try {
|
|
const params = buildSearchParams()
|
|
if (query.enabled !== '') params.enabled = Number(query.enabled)
|
|
const resp = await http.get<Blob>('/user/user/export', {
|
|
params,
|
|
responseType: 'blob'
|
|
})
|
|
const blob = resp.data
|
|
if (!blob || blob.size === 0) {
|
|
ElMessage.error('导出内容为空')
|
|
return
|
|
}
|
|
// 优先从响应头取文件名 (后端 ExcelUtils 会设置)
|
|
const dispo = resp.headers?.['content-disposition'] || resp.headers?.['Content-Disposition']
|
|
let filename = `用户列表_${new Date().toISOString().slice(0, 10)}.xls`
|
|
if (typeof dispo === 'string') {
|
|
const m = dispo.match(/filename\*?=(?:UTF-8\u0027\u0027)?["']?([^;"']+)["']?/i)
|
|
if (m && m[1]) {
|
|
try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] }
|
|
}
|
|
}
|
|
const url = URL.createObjectURL(new Blob([blob]))
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
URL.revokeObjectURL(url)
|
|
ElMessage.success(`已导出 ${filename}`)
|
|
} catch (e: any) {
|
|
const msg = e?.msg || e?.message || '导出失败'
|
|
ElMessage.error(msg)
|
|
} finally {
|
|
exporting.value = false
|
|
}
|
|
}
|
|
|
|
async function toggleEnabled(row: FrontUser) {
|
|
// 当前 enabled: 0=正常 1=禁用; 要切换到对端
|
|
const next = row.enabled === 0 ? 1 : 0
|
|
const action = next === 1 ? '禁用' : '启用'
|
|
try {
|
|
await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' })
|
|
} catch { return }
|
|
try {
|
|
await httpPost('/user/user/update', { id: row.id, enabled: next })
|
|
ElMessage.success(`${action}成功`)
|
|
row.enabled = next
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.msg || e?.message || `${action}失败`)
|
|
}
|
|
}
|
|
|
|
async function deleteUser(row: FrontUser) {
|
|
try {
|
|
await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' })
|
|
} catch { return }
|
|
try {
|
|
await httpPost('/user/user/delete', { id: row.id })
|
|
ElMessage.success('删除成功')
|
|
load()
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.msg || e?.message || '删除失败')
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rr-page">
|
|
<PageHeader title="用户列表" :sub="`管理前台注册用户,共 ${enabledStats.normal + enabledStats.disabled} 个账号`">
|
|
<el-button @click="exportExcel" :loading="exporting">导出</el-button>
|
|
<el-button type="primary" @click="onCreate">
|
|
<el-icon style="margin-right: 4px; vertical-align: -2px;"><Plus /></el-icon>
|
|
新建用户
|
|
</el-button>
|
|
</PageHeader>
|
|
<UserFormDialog
|
|
v-model:visible="dialogVisible"
|
|
:user-id="editingUserId"
|
|
@success="onDialogSuccess"
|
|
/>
|
|
|
|
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" />
|
|
|
|
<SearchBar
|
|
v-model:filters="query"
|
|
:fields="[
|
|
{ key: 'userId', label: '用户ID', type: 'text', placeholder: '用户ID' },
|
|
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' },
|
|
{ key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' },
|
|
{ key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '所有状态', width: '160px',
|
|
options: [{ label: '已认证', value: '1' }, { label: '未认证', value: '0' }] },
|
|
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' }
|
|
]"
|
|
@search="onSearch"
|
|
@reset="onReset"
|
|
/>
|
|
|
|
<DataTable
|
|
:columns="columns"
|
|
:data="list"
|
|
:loading="loading"
|
|
:total="total"
|
|
:page="query.page"
|
|
:limit="query.limit"
|
|
:page-sizes="[10, 20, 50, 100]"
|
|
:show-pagination="true"
|
|
:borderless="true"
|
|
row-key="id"
|
|
empty-text="暂无用户"
|
|
@page-change="onPageChange"
|
|
@limit-change="onLimitChange"
|
|
>
|
|
<template #enabled="{ row }">
|
|
<el-tag v-if="row.enabled === 0" type="success" size="small" effect="light" round>正常</el-tag>
|
|
<el-tag v-else type="danger" size="small" effect="light" round>禁用</el-tag>
|
|
</template>
|
|
<template #action="{ row }">
|
|
<TableActions
|
|
:row="row"
|
|
:actions="[
|
|
{ label: '修改', type: 'primary', onClick: onEdit },
|
|
{ label: (r: any) => r.enabled === 0 ? '禁用' : '启用', type: (r: any) => r.enabled === 0 ? 'warning' : 'success', onClick: toggleEnabled },
|
|
{ label: '删除', type: 'danger', onClick: deleteUser }
|
|
]"
|
|
/>
|
|
</template>
|
|
</DataTable>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.rr-page { max-width: 100%; }
|
|
</style>
|