658 lines
22 KiB
Vue
658 lines
22 KiB
Vue
<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>
|