admin_new/src/modules/user/UserListView.vue

433 lines
17 KiB
Vue

<script setup lang="ts">
/**
* F006 用户管理 (前台用户列表) - 2026-08-18 v2 简化版
*
* 用户反馈: 列表内容简化 - 单行显示、无头像、字段保持原样 (仅 UI 美化)
* 字段: 用户昵称 / 手机号 / 是否企业认证 / 关联企业 / 平台账户余额 / 禁用标识 / 注册时间 / 操作 (用户ID 已按需求隐藏)
* 后端端点: GET /user/user/page / GET /user/user/export / POST /user/user/update / POST /user/user/delete
* 保留功能: tab 全部/正常/已禁用(stats 与 tab 解耦) / 搜索 / 导出 / 新建编辑(UserFormDialog) / 批量操作
*/
import { onMounted, ref, reactive, computed } from 'vue'
import { http, httpGet, httpPost } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Phone, Clock } 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 selectedIds = ref<string[]>([])
const selectedCount = ref(0)
// 表单弹窗状态
const dialogVisible = ref(false)
const editingUserId = ref<string | number | null>(null)
const exporting = ref(false)
const query = reactive({
page: 1,
limit: 10,
nickName: '',
mobile: '',
enterpriseName: '',
isEnterprise: '' as '' | '0' | '1',
enabled: '' as '' | '0' | '1'
})
// 状态/认证胶囊色 (引用全局 --st-* 变量, 主题切换自动适配暗色)
const ENABLED_META: Record<number, { label: string; color: string; bg: string }> = {
0: { label: '正常', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
1: { label: '禁用', color: 'var(--st-err-fg)', bg: 'var(--st-err-bg)' }
}
const CERT_META: Record<number, { label: string; color: string; bg: string }> = {
1: { label: '是', color: 'var(--st-ok-fg)', bg: 'var(--st-ok-bg)' },
0: { label: '否', color: 'var(--st-lv4-fg)', bg: 'var(--st-lv4-bg)' }
}
const columns: any[] = [
{ type: 'selection', width: 48, fixed: 'left', align: 'center' },
{ prop: 'nickName', label: '用户昵称', minWidth: 200, slot: 'nickName' },
{ prop: 'mobile', label: '手机号', width: 140, slot: 'mobile' },
{ prop: 'isEnterprise', label: '是否企业认证', width: 120, slot: 'cert', align: 'center' },
{ prop: 'enterpriseName', label: '关联企业', minWidth: 180, slot: 'enterpriseName' },
{ prop: 'accountBalance', label: '平台账户余额', width: 150, slot: 'balance', align: 'right' },
{ prop: 'enabled', label: '禁用标识', width: 100, slot: 'enabled', align: 'center' },
{ label: '注册时间', width: 165, slot: 'createDate' },
{ label: '操作', width: 170, slot: 'action', fixed: 'right', align: 'center' }
]
function fmtMoney(n: number): string {
const v = Number(n || 0)
return `¥ ${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
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.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 无关 */
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(() => [
{ 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.nickName = ''; query.mobile = ''
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
activeTab.value = 'all'
query.page = 1
load()
}
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id); selectedCount.value = rows.length }
function onEdit(row: FrontUser) {
editingUserId.value = row.id
dialogVisible.value = true
}
function onCreate() {
editingUserId.value = null
dialogVisible.value = true
}
function onDialogSuccess() {
load()
}
/** 导出当前搜索条件下的用户列表为 Excel */
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 }
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) {
ElMessage.error(e?.msg || e?.message || '导出失败')
} finally {
exporting.value = false
}
}
// ========== 批量操作 ==========
async function batchToggle(next: number) {
const action = next === 1 ? '禁用' : '启用'
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
try {
await ElMessageBox.confirm(`确认${action}已选 ${selectedIds.value.length} 个用户?`, `批量${action}确认`, { type: 'warning' })
} catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpPost('/user/user/update', { id, enabled: next }); ok++ } catch {/* 单条跳过 */}
}
ElMessage.success(`已${action} ${ok} 个用户`)
load()
}
async function batchDelete() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择用户'); return }
try {
await ElMessageBox.confirm(`确认删除已选 ${selectedIds.value.length} 个用户?该操作不可恢复`, '批量删除确认', { type: 'warning' })
} catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpPost('/user/user/delete', { id }); ok++ } catch {/* 单条跳过 */}
}
ElMessage.success(`已删除 ${ok} 个用户`)
load()
}
async function toggleEnabled(row: FrontUser) {
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: '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"
/>
<div class="rr-list-card">
<!-- 批量操作栏 -->
<div class="batch-bar" :class="{ 'has-selection': selectedCount > 0 }">
<span v-if="selectedCount > 0" class="batch-count">
已选 <strong>{{ selectedCount }}</strong> 个用户
</span>
<span v-else class="batch-hint">勾选用户后可批量操作</span>
<span class="batch-actions">
<el-button size="small" type="warning" plain :disabled="!selectedCount" @click="batchToggle(1)">批量禁用</el-button>
<el-button size="small" type="success" plain :disabled="!selectedCount" @click="batchToggle(0)">批量启用</el-button>
<el-button size="small" type="danger" :disabled="!selectedCount" @click="batchDelete">批量删除</el-button>
</span>
</div>
<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"
@selection-change="onSelectionChange"
>
<!-- 用户昵称: 单行 -->
<template #nickName="{ row }">
<span class="nick-cell">{{ row.nickName }}</span>
</template>
<!-- 手机号 -->
<template #mobile="{ row }">
<span class="phone-cell"><el-icon><Phone /></el-icon>{{ row.mobile }}</span>
</template>
<!-- 是否企业认证: 胶囊 -->
<template #cert="{ row }">
<span class="chip" :style="{ color: CERT_META[row.isEnterprise].color, background: CERT_META[row.isEnterprise].bg }">
{{ CERT_META[row.isEnterprise].label }}
</span>
</template>
<!-- 关联企业: 单行 -->
<template #enterpriseName="{ row }">
<span class="text-ellipsis">{{ row.enterpriseName || '--' }}</span>
</template>
<!-- 平台余额: 货币化 -->
<template #balance="{ row }">
<span class="balance-cell">{{ fmtMoney(row.accountBalance) }}</span>
</template>
<!-- 禁用标识: 圆点胶囊 -->
<template #enabled="{ row }">
<span class="status-tag" :style="{ color: ENABLED_META[row.enabled].color, background: ENABLED_META[row.enabled].bg }">
<span class="status-dot"></span>{{ ENABLED_META[row.enabled].label }}
</span>
</template>
<!-- 注册时间 -->
<template #createDate="{ row }">
<span class="date-cell"><el-icon><Clock /></el-icon>{{ fmtDate(row.createDate) }}</span>
</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>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 100%; padding: 16px; }
.rr-list-card {
background: var(--surface); border-radius: 12px; overflow: hidden;
}
/* ===== 批量操作栏 ===== */
.batch-bar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
transition: background .2s;
}
.batch-bar.has-selection { background: var(--primary-soft, #e0ecff); }
.batch-count { font-size: 13px; color: var(--ink-2); }
.batch-count strong { color: var(--primary); font-size: 15px; }
.batch-hint { font-size: 12px; color: var(--ink-3); }
.batch-actions { margin-left: auto; display: flex; gap: 4px; flex-wrap: wrap; }
/* ===== 单元格 (单行) ===== */
.nick-cell { font-size: 14px; font-weight: 600; color: var(--ink); }
.text-ellipsis {
display: inline-block; max-width: 100%;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
vertical-align: middle; font-size: 13px; color: var(--ink);
}
.phone-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.date-cell { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--ink-2); font-family: 'SF Mono', Menlo, Consolas, monospace; }
.balance-cell { font-size: 13px; font-weight: 600; color: var(--ink); font-family: 'SF Mono', Menlo, Consolas, monospace; }
/* ===== 胶囊 (全局 --st-* 变量暗色自适应) ===== */
.chip {
display: inline-flex; align-items: center; width: fit-content;
font-size: 12px; font-weight: 500; padding: 2px 12px; border-radius: 20px; white-space: nowrap;
}
.status-tag {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 500; padding: 3px 12px; border-radius: 20px; white-space: nowrap;
}
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
</style>