Files
qitongxue-admin/src/views/users/UserList.vue
2026-09-29 10:58:25 +08:00

658 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message, notification } from 'ant-design-vue'
import { userListApi, userStatusApi, userBatchApi } from '@/api/request/user'
import { levelListApi } from '@/api/request/level'
import { auditRecordApi } from '@/api/request/admin'
import { exportRows } from '@/utils/export'
import PageShell from '@/components/PageShell.vue'
import GlassCard from '@/components/GlassCard.vue'
import StateBlock from '@/components/StateBlock.vue'
import AppModal, { appConfirm } from '@/components/AppModal.vue'
import LevelModal from '@/components/user/LevelModal.vue'
import UserDetailDrawer from '@/components/user/UserDetailDrawer.vue'
import GrantCouponModal from '@/components/user/GrantCouponModal.vue'
const route = useRoute()
const router = useRouter()
// 默认(免费)等级 key,与后端 consts.DefaultLevelKey 对齐(仅用于「未开通」展示判定)。
const DEFAULT_LEVEL_KEY = 'v1'
const loading = ref(false)
const loadError = ref('')
const list = ref([])
const total = ref(0)
const levels = ref([])
// ===== 查询状态(多条件 + 排序,全部可经 URL 还原)=====
const DEFAULT_PAGE_SIZE = 20
const query = reactive({
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
keyword: '',
levelKey: undefined, // 等级
status: undefined, // 状态:0/1/undefined
range: undefined, // 注册时间范围:'7'|'30'|'90'|undefined
memberExpire: undefined, // 会员到期:expiring7|expiring30|expired|forever|none|undefined
sortBy: undefined, // created_at / status / level_key
sortOrder: undefined, // asc / desc
})
const RANGE_OPTIONS = [
{ value: '7', label: '近 7 天' },
{ value: '30', label: '近 30 天' },
{ value: '90', label: '近 90 天' },
]
const STATUS_OPTIONS = [
{ value: 1, label: '启用' },
{ value: 0, label: '禁用' },
]
// 会员到期筛选(需求 7 / M7-6)。值进 URL,与后端 /user/list 的 memberExpire 入参一致。
const MEMBER_EXPIRE_OPTIONS = [
{ value: 'expiring7', label: '7 天内到期' },
{ value: 'expiring30', label: '30 天内到期' },
{ value: 'expired', label: '已过期' },
{ value: 'forever', label: '永久' },
{ value: 'none', label: '未开通' },
]
// 把 range 键换算成具体日期区间(YYYY-MM-DD),供后端 created_at 过滤
function resolveDateRange() {
const days = Number(query.range)
if (!days || days <= 0) return { dateFrom: undefined, dateTo: undefined }
const fmt = (d) =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const to = new Date()
const from = new Date()
from.setDate(from.getDate() - (days - 1))
return { dateFrom: fmt(from), dateTo: fmt(to) }
}
// ===== 列定义(响应一律读 snake_case;可排序列注入 sorter)=====
const baseColumns = [
{ title: '用户', key: 'name' },
{ title: 'openid', dataIndex: 'openid', key: 'openid', ellipsis: true },
{ title: '等级', key: 'level', width: 140, sorter: true, sorterKey: 'level_key' },
{ title: '会员到期', key: 'memberExpire', width: 130 },
{ title: '使用次数', dataIndex: 'usage_count', key: 'usage_count', width: 100, align: 'right' },
{ title: '最近使用', dataIndex: 'last_used_at', key: 'last_used_at', width: 180 },
{ title: '注册时间', dataIndex: 'created_at', key: 'created_at', width: 180, sorter: true, sorterKey: 'created_at' },
{ title: '状态', key: 'status', width: 90, sorter: true, sorterKey: 'status' },
{ title: '操作', key: 'action', width: 150, align: 'right' },
]
const columns = computed(() =>
baseColumns.map((c) => {
if (!c.sorter) return c
let sortOrder = null
if (query.sortBy === c.sorterKey) {
sortOrder = query.sortOrder === 'asc' ? 'ascend' : 'descend'
}
return { ...c, sortOrder, sortDirections: ['ascend', 'descend'] }
}),
)
const exportColumns = [
{ title: 'ID', dataIndex: 'id' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: 'openid', dataIndex: 'openid' },
{ title: '等级', key: 'level_name' },
{ title: '会员到期', dataIndex: 'level_expire_at' },
{ title: '使用次数', dataIndex: 'usage_count' },
{ title: '最近使用', dataIndex: 'last_used_at' },
{ title: '注册时间', dataIndex: 'created_at' },
{ title: '状态', key: 'status' },
]
// ===== 请求参数构造(请求体 camelCase)=====
function buildParams() {
const { dateFrom, dateTo } = resolveDateRange()
return {
page: query.page,
pageSize: query.pageSize,
keyword: query.keyword || undefined,
levelKey: query.levelKey || undefined,
status: query.status, // undefined 会被 axios 丢弃
dateFrom,
dateTo,
memberExpire: query.memberExpire || undefined,
sortBy: query.sortBy || undefined,
sortOrder: query.sortOrder || undefined,
}
}
// ===== URL 同步 =====
// suppressWatcher:自己 replace URL 时置位,避免 watcher 重复加载;
// 外部变化(浏览器前进/后退、手改 URL)时 watcher 才重新同步并加载。
let suppressWatcher = false
function buildQuery() {
const q = {}
if (query.keyword) q.keyword = query.keyword
if (query.levelKey) q.levelKey = query.levelKey
if (query.status !== undefined && query.status !== null) q.status = query.status
if (query.range) q.range = query.range
if (query.memberExpire) q.memberExpire = query.memberExpire
if (query.sortBy) q.sortBy = query.sortBy
if (query.sortOrder) q.sortOrder = query.sortOrder
if (query.page && query.page !== 1) q.page = query.page
if (query.pageSize && query.pageSize !== DEFAULT_PAGE_SIZE) q.pageSize = query.pageSize
return q
}
function syncFromQuery(q = route.query) {
query.keyword = q.keyword || ''
query.levelKey = q.levelKey || undefined
query.status = q.status !== undefined && q.status !== '' ? Number(q.status) : undefined
query.range = q.range || undefined
query.memberExpire = q.memberExpire || undefined
query.sortBy = q.sortBy || undefined
query.sortOrder = q.sortOrder || undefined
query.page = q.page ? Number(q.page) : 1
query.pageSize = q.pageSize ? Number(q.pageSize) : DEFAULT_PAGE_SIZE
}
// 用户主动改条件:先落 URL 再拉数据(URL 变化由 watcher 兜底前进/后退)
function reload() {
suppressWatcher = true
Promise.resolve(router.replace({ query: buildQuery() })).finally(() => {
suppressWatcher = false
})
return doLoad()
}
function applyFilters() {
query.page = 1
reload()
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
levelKey: undefined,
status: undefined,
range: undefined,
memberExpire: undefined,
sortBy: undefined,
sortOrder: undefined,
})
reload()
}
// ===== 数据加载 =====
async function doLoad() {
loading.value = true
loadError.value = ''
try {
const res = await userListApi(buildParams())
if (res) {
list.value = res.list || []
total.value = res.total || 0
}
} catch {
loadError.value = '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
async function loadLevels() {
try {
const res = await levelListApi()
// 响应 snake_case:is_enabled / level_key
levels.value = (res?.list || []).filter((l) => l.is_enabled === 1)
} catch {
levels.value = []
}
}
const levelOptions = computed(() => levels.value.map((l) => ({ value: l.level_key, label: l.name })))
// 外部(浏览器前进/后退 / 手改 URL)触发的 query 变化 → 重新同步并加载
watch(
() => route.query,
() => {
if (suppressWatcher) return // 我们自己 replace 触发的,跳过
if (route.path !== '/users') return
syncFromQuery()
doLoad()
},
)
onMounted(() => {
syncFromQuery()
loadLevels()
doLoad()
})
// ===== 表格分页 / 排序 =====
function onTableChange(pag, _filters, sorter) {
query.page = pag.current
query.pageSize = pag.pageSize
if (sorter && sorter.order && sorter.column && sorter.column.sorterKey) {
query.sortBy = sorter.column.sorterKey
query.sortOrder = sorter.order === 'ascend' ? 'asc' : 'desc'
} else {
query.sortBy = undefined
query.sortOrder = undefined
}
reload()
}
// ===== 会员到期展示(仅展示,判定以后端 EffectiveLevel 为准)=====
function expireMeta(record) {
if (record.level_expire_at) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const expired = new Date(`${record.level_expire_at}T00:00:00`).getTime() < today.getTime()
return { text: record.level_expire_at, color: expired ? 'error' : 'success' }
}
if (record.level_key && record.level_key !== DEFAULT_LEVEL_KEY) {
return { text: '永久', color: 'success' }
}
return { text: '未开通', color: 'default' }
}
// ===== 行内操作 =====
async function toggleStatus(record) {
try {
await userStatusApi({ userId: record.id, status: record.status })
message.success(record.status === 1 ? '已启用' : '已禁用')
} catch {
record.status = record.status === 1 ? 0 : 1
}
}
// ===== 等级模态框(T03.4)=====
const levelModalOpen = ref(false)
const levelModalUser = ref(null)
function openLevelModal(record) {
levelModalUser.value = { ...record }
levelModalOpen.value = true
}
function onLevelSaved() {
levelModalOpen.value = false
doLoad()
}
// ===== 用户详情抽屉(T03.5)=====
const detailOpen = ref(false)
const detailUserId = ref(0)
function openDetail(record) {
detailUserId.value = record.id
detailOpen.value = true
}
// ===== 发放优惠券(N6 承前;/coupon/grant 已就绪)=====
const grantOpen = ref(false)
const grantUserIds = ref([])
const grantBatch = ref(false)
function openGrantSingle(record) {
grantUserIds.value = [record.id]
grantBatch.value = false
grantOpen.value = true
}
function openGrantBatch() {
if (!selectedRowKeys.value.length) return
grantUserIds.value = [...selectedRowKeys.value]
grantBatch.value = true
grantOpen.value = true
}
function onGrantDone() {
grantOpen.value = false
selectedRowKeys.value = []
}
// ===== 批量操作 =====
const selectedRowKeys = ref([])
const batchLoading = ref(false)
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
},
}))
const hasSelection = computed(() => selectedRowKeys.value.length > 0)
function reportBatch(res) {
if (!res) return
if (res.failed > 0) {
notification.warning({
message: `部分成功:成功 ${res.success} 条,失败 ${res.failed} 条`,
description: (res.failures || []).map((f) => `#${f.id} ${f.reason}`).join(';'),
duration: 6,
})
} else {
message.success(`操作成功 ${res.success} 条`)
}
}
async function runBatch(action, payload) {
if (!hasSelection.value) return
batchLoading.value = true
try {
const res = await userBatchApi({ ids: [...selectedRowKeys.value], action, ...payload })
reportBatch(res)
selectedRowKeys.value = []
reload()
} catch {
/* 拦截器已提示 */
} finally {
batchLoading.value = false
}
}
function confirmBatchStatus(status) {
appConfirm({
title: status === 0 ? `确认批量禁用 ${selectedRowKeys.value.length} 位用户?` : `确认批量启用 ${selectedRowKeys.value.length} 位用户?`,
okType: status === 0 ? 'danger' : 'primary',
okText: '确认',
onOk: () => runBatch('status', { status }),
})
}
function confirmBatchDelete() {
appConfirm({
title: `确认删除选中的 ${selectedRowKeys.value.length} 位用户?`,
content: '软删除,可恢复。删除后用户不再出现在列表与统计中;其手机号 / openid 可重新注册。',
okType: 'danger',
okText: '删除',
onOk: () => runBatch('delete'),
})
}
// ===== 筛选方案(localStorage 保存复用)=====
const PRESET_KEY = 'qtx.user.filter.presets'
const presets = ref([])
const presetModalOpen = ref(false)
const presetName = ref('')
function loadPresets() {
try {
presets.value = JSON.parse(localStorage.getItem(PRESET_KEY) || '[]')
} catch {
presets.value = []
}
}
function persistPresets() {
try {
localStorage.setItem(PRESET_KEY, JSON.stringify(presets.value))
} catch {
/* 忽略隐私模式写入失败 */
}
}
function currentFilters() {
return {
keyword: query.keyword || '',
levelKey: query.levelKey || undefined,
status: query.status,
range: query.range || undefined,
memberExpire: query.memberExpire || undefined,
sortBy: query.sortBy || undefined,
sortOrder: query.sortOrder || undefined,
}
}
function openSavePreset() {
presetName.value = ''
presetModalOpen.value = true
}
function confirmSavePreset() {
const name = presetName.value.trim()
if (!name) {
message.warning('请填写方案名称')
return
}
const idx = presets.value.findIndex((p) => p.name === name)
const item = { name, filters: currentFilters() }
if (idx >= 0) presets.value[idx] = item
else presets.value.push(item)
persistPresets()
presetModalOpen.value = false
message.success('已保存筛选方案')
}
function applyPreset(name) {
const p = presets.value.find((x) => x.name === name)
if (!p) return
const f = p.filters || {}
Object.assign(query, {
page: 1,
keyword: f.keyword || '',
levelKey: f.levelKey || undefined,
status: f.status,
range: f.range || undefined,
memberExpire: f.memberExpire || undefined,
sortBy: f.sortBy || undefined,
sortOrder: f.sortOrder || undefined,
})
reload()
message.success(`已应用方案 ${name}`)
}
function removePreset(name) {
appConfirm({
title: `删除筛选方案「${name}」?`,
okType: 'danger',
okText: '删除',
onOk: () => {
presets.value = presets.value.filter((x) => x.name !== name)
persistPresets()
message.success('已删除')
},
})
}
// ===== 导出(导出当前筛选结果)=====
const exporting = ref(false)
async function exportData(format) {
exporting.value = true
try {
const res = await userListApi({
...buildParams(),
page: 1,
pageSize: Math.min(Math.max(total.value, query.pageSize), 5000),
})
const rows = res?.list || list.value
exportRows({
columns: exportColumns,
rows,
filename: '用户列表',
format,
formatter: {
level_name: (v, row) => row.level_name || row.level_key || '',
status: (v) => (v === 1 ? '启用' : '禁用'),
},
})
message.success(`已导出 ${rows.length} 条`)
// 导出为纯前端动作,服务端无法感知 → 主动上报审计
auditRecordApi({
action: 'export',
target_type: 'user',
remark: `format=${format} count=${rows.length} filters=${JSON.stringify(buildQuery())}`,
}).catch(() => {})
} catch {
message.warning('导出失败,请稍后重试')
} finally {
exporting.value = false
}
}
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
const hasFilter = computed(
() =>
!!query.keyword ||
!!query.levelKey ||
query.status !== undefined ||
!!query.range ||
!!query.memberExpire,
)
onMounted(loadPresets)
</script>
<template>
<PageShell title="用户管理" desc="多条件筛选、排序与批量操作">
<template #actions>
<a-input-search v-model:value="query.keyword" placeholder="昵称 / openid" style="width: 220px" allowClear
@search="applyFilters" />
<a-button @click="reload">刷新</a-button>
<a-dropdown>
<a-button :loading="exporting">导出</a-button>
<template #overlay>
<a-menu @click="({ key }) => exportData(key)">
<a-menu-item key="xlsx">导出 Excel (.xlsx)</a-menu-item>
<a-menu-item key="csv">导出 CSV (.csv)</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<!-- 多条件筛选 -->
<div class="app-toolbar filter-bar">
<a-select v-model:value="query.levelKey" placeholder="全部等级" allowClear style="width: 140px"
:options="levelOptions" @change="applyFilters" />
<a-select v-model:value="query.status" placeholder="全部状态" allowClear style="width: 120px"
:options="STATUS_OPTIONS" @change="applyFilters" />
<a-select v-model:value="query.range" placeholder="全部时间" allowClear style="width: 130px"
:options="RANGE_OPTIONS" @change="applyFilters" />
<a-select v-model:value="query.memberExpire" placeholder="会员到期" allowClear style="width: 140px"
:options="MEMBER_EXPIRE_OPTIONS" @change="applyFilters" />
<a-button type="primary" @click="applyFilters">查询</a-button>
<a-button :disabled="!hasFilter" @click="resetFilters">重置</a-button>
<div class="app-toolbar__grow" />
<a-button size="small" @click="openSavePreset">保存方案</a-button>
<template v-if="presets.length">
<a-tag v-for="p in presets" :key="p.name" color="orange" class="preset-tag"
@click="applyPreset(p.name)">
{{ p.name }}
<span class="preset-tag__x" @click.stop="removePreset(p.name)">×</span>
</a-tag>
</template>
</div>
<!-- 批量操作条 -->
<div v-if="hasSelection" class="app-toolbar batch-bar">
<span class="batch-bar__count">已选 {{ selectedRowKeys.length }} 项</span>
<a-button size="small" :loading="batchLoading" @click="runBatch('status', { status: 1 })">批量启用</a-button>
<a-button size="small" danger :loading="batchLoading" @click="confirmBatchStatus(0)">批量禁用</a-button>
<a-button size="small" danger :loading="batchLoading" @click="confirmBatchDelete">批量删除</a-button>
<a-dropdown>
<a-button size="small" :loading="batchLoading">批量设等级</a-button>
<template #overlay>
<a-menu @click="({ key }) => runBatch('level', { level_key: key })">
<a-menu-item v-for="l in levels" :key="l.level_key">{{ l.name }}</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button size="small" @click="openGrantBatch">批量发券</a-button>
<a-button size="small" type="text" @click="selectedRowKeys = []">取消选择</a-button>
</div>
<GlassCard>
<StateBlock :error="loadError" :empty="isEmpty" empty-text="没有符合条件的用户" @retry="reload">
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
:row-selection="rowSelection"
:pagination="{
current: query.page,
pageSize: query.pageSize,
total,
showSizeChanger: false,
showTotal: (t) => `共 ${t} 位用户`,
}" @change="onTableChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="flex items-center gap-2">
<span class="user-avatar">
<img v-if="record.avatar_url" :src="record.avatar_url" class="w-9 h-9 rounded-xl object-cover" alt="" />
<template v-else>{{ (record.nickname || '微').slice(0, 1) }}</template>
</span>
<span class="font-medium" style="color: var(--text-1)">{{ record.nickname }}</span>
</div>
</template>
<template v-else-if="column.key === 'level'">
<a-button type="link" size="small" style="padding: 0" @click="openLevelModal(record)">
{{ record.level_name || record.level_key || '—' }}
<span class="app-muted">(改)</span>
</a-button>
</template>
<template v-else-if="column.key === 'memberExpire'">
<a-tag :color="expireMeta(record).color">{{ expireMeta(record).text }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-switch :checked="record.status === 1" size="small"
@change="(v) => { record.status = v ? 1 : 0; toggleStatus(record) }" />
</template>
<template v-else-if="column.key === 'action'">
<a-space size="small">
<a-button type="link" size="small" @click="openDetail(record)">详情</a-button>
<a-button type="link" size="small" @click="openGrantSingle(record)">发券</a-button>
</a-space>
</template>
</template>
</a-table>
</StateBlock>
</GlassCard>
<!-- 保存方案 -->
<AppModal v-model:open="presetModalOpen" title="保存筛选方案" ok-text="保存" @ok="confirmSavePreset">
<a-form layout="vertical">
<a-form-item label="方案名称" required>
<a-input v-model:value="presetName" placeholder="如 近 30 天未活跃会员" :maxlength="32" @press-enter="confirmSavePreset" />
</a-form-item>
<div class="app-muted" style="font-size: var(--font-xs)">
将保存当前「关键词 / 等级 / 状态 / 时间范围 / 会员到期 / 排序」条件,保存在本机浏览器,可随时一键复用。
</div>
</a-form>
</AppModal>
<!-- 等级 / 会员开通模态框(T03.4)-->
<LevelModal v-model:open="levelModalOpen" :user="levelModalUser" :levels="levels" @saved="onLevelSaved" />
<!-- 用户详情抽屉(T03.5)-->
<UserDetailDrawer v-model:open="detailOpen" :user-id="detailUserId" />
<!-- 发放优惠券(N6 承前;后端 /coupon/grant 已就绪)-->
<GrantCouponModal v-model:open="grantOpen" :user-ids="grantUserIds" :batch="grantBatch" @done="onGrantDone" />
</PageShell>
</template>
<style scoped>
.filter-bar {
flex-wrap: wrap;
margin-bottom: var(--space-4);
}
.batch-bar {
padding: var(--space-2) var(--space-3);
background: var(--color-primary-soft);
border: 1px solid var(--border-1);
border-radius: var(--radius-md);
}
.batch-bar__count {
font-size: var(--font-sm);
color: var(--color-primary);
font-weight: 600;
}
.preset-tag {
cursor: pointer;
user-select: none;
}
.preset-tag__x {
margin-left: 4px;
font-size: 11px;
}
.user-avatar {
display: inline-flex;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
align-items: center;
justify-content: center;
background: var(--color-primary-soft);
color: var(--color-primary);
overflow: hidden;
}
</style>