513 lines
14 KiB
Vue
513 lines
14 KiB
Vue
<template>
|
||
<div class="aksrt-page-container">
|
||
<PageHeader title="邀请码运营" desc="统一管理用户邀请码与独立邀请码,支持批量生成、批量禁用、备注追踪和筛选导出。" />
|
||
|
||
<div class="aksrt-invite-ops__stats">
|
||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
||
</n-card>
|
||
</div>
|
||
|
||
<n-grid cols="1 s:1 m:2" :x-gap="16" :y-gap="16" responsive="screen">
|
||
<n-grid-item>
|
||
<n-card title="批量生成邀请码" size="small">
|
||
<n-form :model="generateForm" label-placement="top">
|
||
<n-grid cols="2" :x-gap="12">
|
||
<n-grid-item>
|
||
<n-form-item label="生成数量">
|
||
<n-input-number v-model:value="generateForm.count" :min="1" :max="100" style="width: 100%;" />
|
||
</n-form-item>
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<n-form-item label="随机长度">
|
||
<n-input-number v-model:value="generateForm.length" :min="4" :max="12" style="width: 100%;" />
|
||
</n-form-item>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
<n-form-item label="前缀">
|
||
<n-input v-model:value="generateForm.prefix" placeholder="AK" />
|
||
</n-form-item>
|
||
<n-form-item label="批次备注">
|
||
<n-input
|
||
v-model:value="generateForm.note"
|
||
type="textarea"
|
||
:autosize="{ minRows: 2, maxRows: 4 }"
|
||
placeholder="例如:618 拉新活动 / 渠道合作批次"
|
||
/>
|
||
</n-form-item>
|
||
<n-space>
|
||
<n-button type="primary" :loading="generating" @click="handleGenerate">批量生成</n-button>
|
||
<n-button @click="loadData">刷新列表</n-button>
|
||
</n-space>
|
||
</n-form>
|
||
</n-card>
|
||
</n-grid-item>
|
||
|
||
<n-grid-item>
|
||
<n-card title="批次输出" size="small">
|
||
<n-alert type="info" :show-icon="false">
|
||
独立邀请码会接入注册校验链路;导出前建议补齐批次备注,便于渠道回溯。
|
||
</n-alert>
|
||
<div v-if="generatedCodes.length" class="aksrt-invite-ops__generated">
|
||
<n-tag type="success" round :bordered="false">批次 {{ generatedBatchId || '-' }}</n-tag>
|
||
<n-input
|
||
type="textarea"
|
||
:value="generatedCodes.join('\n')"
|
||
readonly
|
||
:autosize="{ minRows: 7, maxRows: 10 }"
|
||
/>
|
||
</div>
|
||
<n-empty v-else description="生成后的邀请码会显示在这里" />
|
||
</n-card>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
|
||
<n-card title="邀请码台账" size="small" class="aksrt-invite-ops__table-card">
|
||
<n-space vertical :size="12">
|
||
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
|
||
<n-grid-item>
|
||
<n-input v-model:value="filters.keyword" placeholder="搜索邀请码、批次、用户、备注" clearable />
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<n-select v-model:value="filters.source" :options="sourceOptions" placeholder="来源" />
|
||
</n-grid-item>
|
||
<n-grid-item>
|
||
<n-date-picker
|
||
v-model:value="filters.dateRange"
|
||
type="daterange"
|
||
clearable
|
||
style="width: 100%;"
|
||
/>
|
||
</n-grid-item>
|
||
</n-grid>
|
||
|
||
<n-space justify="space-between" wrap>
|
||
<n-space>
|
||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
||
<n-button @click="resetFilters">重置</n-button>
|
||
<n-button :disabled="!checkedRows.length" @click="handleBulkAction('disable')">批量禁用</n-button>
|
||
<n-button :disabled="!checkedRows.length" @click="handleBulkAction('delete')">批量删除</n-button>
|
||
</n-space>
|
||
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
|
||
</n-space>
|
||
|
||
<n-data-table
|
||
:columns="columns"
|
||
:data="rows"
|
||
:loading="loading"
|
||
:bordered="false"
|
||
:pagination="pagination"
|
||
:row-key="(row) => row.row_key"
|
||
:checked-row-keys="checkedRowKeys"
|
||
@update:checked-row-keys="handleCheckedRowKeysChange"
|
||
/>
|
||
</n-space>
|
||
</n-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
|
||
import PageHeader from '@/components/PageHeader.vue'
|
||
import api from '@/api'
|
||
|
||
const message = useMessage()
|
||
const dialog = useDialog()
|
||
|
||
const loading = ref(false)
|
||
const generating = ref(false)
|
||
const rows = ref([])
|
||
const checkedRowKeys = ref([])
|
||
const generatedCodes = ref([])
|
||
const generatedBatchId = ref('')
|
||
|
||
const stats = reactive({
|
||
total_codes: 0,
|
||
active_codes: 0,
|
||
used_codes: 0,
|
||
disabled_codes: 0,
|
||
user_codes: 0,
|
||
standalone_codes: 0,
|
||
total_invites: 0,
|
||
total_rewarded: 0,
|
||
})
|
||
|
||
const generateForm = reactive({
|
||
count: 20,
|
||
length: 6,
|
||
prefix: 'AK',
|
||
note: '',
|
||
})
|
||
|
||
const filters = reactive({
|
||
keyword: '',
|
||
status: '',
|
||
source: '',
|
||
dateRange: null,
|
||
})
|
||
|
||
const pager = reactive({
|
||
page: 1,
|
||
pageSize: 20,
|
||
itemCount: 0,
|
||
})
|
||
|
||
const optionsState = reactive({
|
||
statuses: [],
|
||
sources: [],
|
||
})
|
||
|
||
const statCards = [
|
||
{ key: 'total_codes', label: '总邀请码' },
|
||
{ key: 'active_codes', label: '当前可用' },
|
||
{ key: 'used_codes', label: '已使用' },
|
||
{ key: 'disabled_codes', label: '已禁用' },
|
||
{ key: 'standalone_codes', label: '独立邀请码' },
|
||
{ key: 'total_rewarded', label: '已完成兑现' },
|
||
]
|
||
|
||
const statusOptions = computed(() => optionsState.statuses.length
|
||
? optionsState.statuses
|
||
: [
|
||
{ label: '全部状态', value: '' },
|
||
{ label: '可用', value: 'active' },
|
||
{ label: '已使用', value: 'used' },
|
||
{ label: '已禁用', value: 'disabled' },
|
||
])
|
||
|
||
const sourceOptions = computed(() => optionsState.sources.length
|
||
? optionsState.sources
|
||
: [
|
||
{ label: '全部来源', value: '' },
|
||
{ label: '用户邀请码', value: 'user' },
|
||
{ label: '独立邀请码', value: 'standalone' },
|
||
])
|
||
|
||
const checkedRows = computed(() => rows.value.filter((row) => checkedRowKeys.value.includes(row.row_key)))
|
||
|
||
const pagination = computed(() => ({
|
||
page: pager.page,
|
||
pageSize: pager.pageSize,
|
||
itemCount: pager.itemCount,
|
||
showSizePicker: true,
|
||
pageSizes: [10, 20, 50, 100],
|
||
onUpdatePage: (page) => {
|
||
pager.page = page
|
||
loadData()
|
||
},
|
||
onUpdatePageSize: (pageSize) => {
|
||
pager.pageSize = pageSize
|
||
pager.page = 1
|
||
loadData()
|
||
},
|
||
}))
|
||
|
||
const columns = [
|
||
{
|
||
type: 'selection',
|
||
disabled: (row) => !row.can_bulk,
|
||
},
|
||
{
|
||
title: '来源',
|
||
key: 'source_type',
|
||
width: 120,
|
||
render: (row) => h(NTag, { size: 'small', round: true, type: row.source_type === 'standalone' ? 'info' : 'default' }, () => row.source_label),
|
||
},
|
||
{
|
||
title: '邀请码',
|
||
key: 'invite_code',
|
||
width: 180,
|
||
ellipsis: { tooltip: true },
|
||
},
|
||
{
|
||
title: '归属/使用人',
|
||
key: 'owner',
|
||
width: 180,
|
||
render: (row) => row.user?.display_name || row.invitee?.display_name || '-',
|
||
},
|
||
{
|
||
title: '状态',
|
||
key: 'status_label',
|
||
width: 110,
|
||
render: (row) => {
|
||
const typeMap = { active: 'success', used: 'warning', disabled: 'error' }
|
||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
||
},
|
||
},
|
||
{
|
||
title: '注册状态',
|
||
key: 'registration_status',
|
||
width: 110,
|
||
render: (row) => row.registration_status || '-',
|
||
},
|
||
{
|
||
title: '批次备注',
|
||
key: 'note',
|
||
minWidth: 180,
|
||
ellipsis: { tooltip: true },
|
||
render: (row) => row.note || row.batch_id || '-',
|
||
},
|
||
{
|
||
title: '创建时间',
|
||
key: 'created_at',
|
||
width: 170,
|
||
},
|
||
{
|
||
title: '使用时间',
|
||
key: 'used_at',
|
||
width: 170,
|
||
render: (row) => row.used_at || '-',
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'actions',
|
||
width: 170,
|
||
render: (row) => h(NSpace, { size: 'small' }, () => {
|
||
const actions = [
|
||
h(NButton, {
|
||
size: 'tiny',
|
||
quaternary: true,
|
||
type: 'warning',
|
||
onClick: () => handleDisable(row),
|
||
}, () => '禁用'),
|
||
]
|
||
|
||
if (row.source_type === 'standalone') {
|
||
actions.push(
|
||
h(NButton, {
|
||
size: 'tiny',
|
||
quaternary: true,
|
||
type: 'error',
|
||
onClick: () => handleDelete(row),
|
||
}, () => '删除'),
|
||
)
|
||
}
|
||
|
||
return actions
|
||
}),
|
||
},
|
||
]
|
||
|
||
onMounted(() => {
|
||
loadData()
|
||
})
|
||
|
||
function formatDateValue(value) {
|
||
const date = new Date(value)
|
||
const year = date.getFullYear()
|
||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||
const day = `${date.getDate()}`.padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
function buildQuery(page = pager.page, pageSize = pager.pageSize) {
|
||
const params = {
|
||
page,
|
||
per_page: pageSize,
|
||
}
|
||
|
||
if (filters.keyword) params.keyword = filters.keyword
|
||
if (filters.status) params.status = filters.status
|
||
if (filters.source) params.source = filters.source
|
||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
||
params.date_from = formatDateValue(filters.dateRange[0])
|
||
params.date_to = formatDateValue(filters.dateRange[1])
|
||
}
|
||
|
||
return params
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const res = await api.request('/invite/codes', { params: buildQuery() })
|
||
const data = res?.data || {}
|
||
rows.value = data.items || []
|
||
pager.itemCount = data.total || 0
|
||
Object.assign(stats, data.stats || {})
|
||
optionsState.statuses = data.options?.statuses || []
|
||
optionsState.sources = data.options?.sources || []
|
||
checkedRowKeys.value = []
|
||
} catch (error) {
|
||
message.error(error?.message || '加载邀请码台账失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pager.page = 1
|
||
loadData()
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.keyword = ''
|
||
filters.status = ''
|
||
filters.source = ''
|
||
filters.dateRange = null
|
||
pager.page = 1
|
||
loadData()
|
||
}
|
||
|
||
function handleCheckedRowKeysChange(keys) {
|
||
checkedRowKeys.value = keys
|
||
}
|
||
|
||
async function handleGenerate() {
|
||
generating.value = true
|
||
try {
|
||
const res = await api.request('/invite/codes', {
|
||
method: 'POST',
|
||
data: {
|
||
count: generateForm.count,
|
||
length: generateForm.length,
|
||
prefix: generateForm.prefix,
|
||
note: generateForm.note,
|
||
},
|
||
})
|
||
generatedCodes.value = res?.data?.codes || []
|
||
generatedBatchId.value = res?.data?.batch_id || ''
|
||
message.success(res?.message || '邀请码已生成')
|
||
await loadData()
|
||
} catch (error) {
|
||
message.error(error?.message || '生成失败')
|
||
} finally {
|
||
generating.value = false
|
||
}
|
||
}
|
||
|
||
function handleDisable(row) {
|
||
dialog.warning({
|
||
title: '确认禁用',
|
||
content: `确定要禁用邀请码 ${row.invite_code} 吗?`,
|
||
positiveText: '禁用',
|
||
negativeText: '取消',
|
||
onPositiveClick: async () => {
|
||
try {
|
||
if (row.source_type === 'user') {
|
||
await api.request(`/invite/codes/${row.user?.id}/disable`, { method: 'POST' })
|
||
} else {
|
||
await api.request('/invite/codes/bulk', {
|
||
method: 'POST',
|
||
data: {
|
||
action: 'disable',
|
||
targets: [{ source_type: 'standalone', code: row.invite_code }],
|
||
},
|
||
})
|
||
}
|
||
message.success('邀请码已禁用')
|
||
await loadData()
|
||
} catch (error) {
|
||
message.error(error?.message || '操作失败')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
function handleDelete(row) {
|
||
dialog.warning({
|
||
title: '确认删除',
|
||
content: `确定要删除独立邀请码 ${row.invite_code} 吗?`,
|
||
positiveText: '删除',
|
||
negativeText: '取消',
|
||
onPositiveClick: async () => {
|
||
try {
|
||
await api.request('/invite/codes/bulk', {
|
||
method: 'POST',
|
||
data: {
|
||
action: 'delete',
|
||
targets: [{ source_type: 'standalone', code: row.invite_code }],
|
||
},
|
||
})
|
||
message.success('邀请码已删除')
|
||
await loadData()
|
||
} catch (error) {
|
||
message.error(error?.message || '删除失败')
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
async function handleBulkAction(action) {
|
||
if (!checkedRows.value.length) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await api.request('/invite/codes/bulk', {
|
||
method: 'POST',
|
||
data: {
|
||
action,
|
||
targets: checkedRows.value.map((row) => ({
|
||
source_type: row.source_type,
|
||
user_id: row.user?.id || 0,
|
||
code: row.invite_code,
|
||
})),
|
||
},
|
||
})
|
||
message.success(action === 'delete' ? '批量删除完成' : '批量禁用完成')
|
||
await loadData()
|
||
} catch (error) {
|
||
message.error(error?.message || '批量操作失败')
|
||
}
|
||
}
|
||
|
||
async function handleExport() {
|
||
try {
|
||
const res = await api.request('/invite/codes', { params: buildQuery(1, 200) })
|
||
const items = res?.data?.items || []
|
||
const header = ['来源', '邀请码', '归属用户', '使用人', '状态', '注册状态', '批次', '备注', '创建时间', '使用时间']
|
||
const lines = items.map((row) => [
|
||
row.source_label || '',
|
||
row.invite_code || '',
|
||
row.user?.display_name || '',
|
||
row.invitee?.display_name || '',
|
||
row.status_label || '',
|
||
row.registration_status || '',
|
||
row.batch_id || '',
|
||
row.note || '',
|
||
row.created_at || '',
|
||
row.used_at || '',
|
||
])
|
||
downloadCsv('invite-codes.csv', [header, ...lines])
|
||
message.success('导出完成')
|
||
} catch (error) {
|
||
message.error(error?.message || '导出失败')
|
||
}
|
||
}
|
||
|
||
function downloadCsv(filename, rows) {
|
||
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.aksrt-invite-ops__stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.aksrt-invite-ops__generated {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.aksrt-invite-ops__table-card {
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|