feat(commerce): 新增打赏任务管理页
web-ci / build (push) Failing after 8s

This commit is contained in:
Zhou Weidong committed 2026-11-09 10:27:50 +08:00
1 parent 96b213354b
commit 88db96c374
2 files changed
+709

No files matched your search

@@ -0,0 +1,616 @@
<!-- Author: fruit.com.cn -->
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import PageHeading from '@/views/components/PageHeading.vue'
import { useRbac } from '@/stores/rbac'
import { dateCell, formatCount } from '@/utils/format'
import {
createRewardTaskApi,
getRewardRulesApi,
manualRewardGrantApi,
pageRewardGrantsApi,
pageRewardTasksApi,
saveRewardRulesApi,
updateRewardTaskApi,
updateRewardTaskStatusApi
} from '@/api/admin/reward'
const { can } = useRbac()
const activeTab = ref('tasks')
const typeOptions = [
['DAILY_CHECK_IN', '每日签到'],
['WATCH_DURATION', '观看时长'],
['WATCH_EPISODES', '观看集数'],
['FIRST_LOGIN', '首次登录'],
['COMPLETE_PROFILE', '完善资料'],
['FIRST_PURCHASE', '首次购买'],
['FOLLOW_SOCIAL', '关注社交账号'],
['INVITE_FRIEND', '邀请好友'],
['CUSTOM_LINK', '自定义任务']
]
const cycleOptions = [
['ONCE', '一次性'],
['DAILY', '每日'],
['WEEKLY', '每周']
]
const sectionOptions = [
['CHECK_IN', '签到奖励'],
['DAILY', '每日任务'],
['NEWBIE', '新手任务'],
['ACTIVITY', '活动任务']
]
const actionOptions = [
['NONE', '无动作'],
['OPEN_HOME', '打开首页'],
['OPEN_PROFILE', '打开个人资料'],
['OPEN_WALLET', '打开钱包'],
['OPEN_URL', '打开链接']
]
const tasks = reactive({ rows: [], total: 0, page: 1, size: 10, loading: false })
const filters = reactive({
keyword: '',
type: undefined,
sectionCode: undefined,
status: undefined
})
const dialogVisible = ref(false)
const saving = ref(false)
const editingId = ref(null)
const emptyForm = () => ({
code: '',
sectionCode: 'DAILY',
type: 'WATCH_DURATION',
verifyMode: 'SERVER',
targetValue: 1,
rewardCoin: 10,
cycleType: 'DAILY',
cycleLimit: 1,
actionType: 'OPEN_HOME',
actionUrl: '',
iconUrl: '',
titleId: '',
titleEn: '',
titleZh: '',
subtitleId: '',
subtitleEn: '',
subtitleZh: '',
ctaId: 'Mulai',
ctaEn: 'Start',
ctaZh: '去完成',
sectionTitleId: 'Tugas harian',
sectionTitleEn: 'Daily tasks',
sectionTitleZh: '每日任务',
configJson: '',
sort: 0,
startAt: null,
endAt: null
})
const form = reactive(emptyForm())
async function loadTasks() {
tasks.loading = true
try {
const res = await pageRewardTasksApi({
pageIndex: tasks.page,
pageSize: tasks.size,
keyword: filters.keyword.trim() || undefined,
type: filters.type,
sectionCode: filters.sectionCode,
status: filters.status
})
tasks.rows = res.data?.list || []
tasks.total = res.data?.total || 0
} finally {
tasks.loading = false
}
}
function searchTasks() {
tasks.page = 1
loadTasks()
}
function resetFilters() {
Object.assign(filters, {
keyword: '',
type: undefined,
sectionCode: undefined,
status: undefined
})
searchTasks()
}
function openCreate() {
editingId.value = null
Object.assign(form, emptyForm(), { sort: tasks.total + 1 })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, emptyForm(), row)
dialogVisible.value = true
}
function validateTask() {
if (!form.code.trim() || !form.titleId.trim() || !form.sectionTitleId.trim())
return '请填写编码、印尼语标题和分组标题'
if (form.targetValue <= 0 || form.rewardCoin < 0) return '目标值必须大于 0,奖励金币不能小于 0'
return ''
}
async function saveTask() {
const invalid = validateTask()
if (invalid) return ElMessage.warning(invalid)
saving.value = true
try {
const payload = {
...form,
code: form.code.trim(),
titleId: form.titleId.trim(),
sectionTitleId: form.sectionTitleId.trim()
}
if (editingId.value) await updateRewardTaskApi(editingId.value, payload)
else await createRewardTaskApi(payload)
ElMessage.success(editingId.value ? '任务已更新' : '任务已创建,默认下线')
dialogVisible.value = false
await loadTasks()
} finally {
saving.value = false
}
}
async function toggleTask(row) {
await updateRewardTaskStatusApi(row.id, row.status === 1 ? 0 : 1)
await loadTasks()
}
const rules = ref([])
const rulesLoading = ref(false)
const rulesSaving = ref(false)
async function loadRules() {
rulesLoading.value = true
try {
rules.value = (await getRewardRulesApi()).data || []
} finally {
rulesLoading.value = false
}
}
async function saveRules() {
rulesSaving.value = true
try {
await saveRewardRulesApi(
rules.value.map(({ dayIndex, rewardCoin }) => ({ dayIndex, rewardCoin }))
)
ElMessage.success('签到规则已保存')
} finally {
rulesSaving.value = false
}
}
const grants = reactive({
rows: [],
total: 0,
page: 1,
size: 10,
loading: false,
userId: '',
status: undefined
})
const grantVisible = ref(false)
const grantSaving = ref(false)
const grantForm = reactive({ userId: '', taskId: '', amount: 10, idempotentKey: '', reason: '' })
async function loadGrants() {
grants.loading = true
try {
const res = await pageRewardGrantsApi({
pageIndex: grants.page,
pageSize: grants.size,
userId: grants.userId || undefined,
status: grants.status
})
grants.rows = res.data?.list || []
grants.total = res.data?.total || 0
} finally {
grants.loading = false
}
}
function queryGrants() {
grants.page = 1
loadGrants()
}
function openGrant() {
Object.assign(grantForm, {
userId: grants.userId,
taskId: '',
amount: 10,
idempotentKey: '',
reason: ''
})
grantVisible.value = true
}
async function confirmGrant() {
if (!grantForm.userId || grantForm.amount <= 0)
return ElMessage.warning('请填写用户 ID 和补发金币')
grantSaving.value = true
try {
await manualRewardGrantApi({
...grantForm,
taskId: grantForm.taskId || undefined,
idempotentKey: grantForm.idempotentKey || undefined
})
ElMessage.success('奖励已通过钱包流水补发')
grantVisible.value = false
await loadGrants()
} finally {
grantSaving.value = false
}
}
function handleTab(name) {
if (name === 'rules' && !rules.value.length) loadRules()
if (name === 'grants' && !grants.rows.length) loadGrants()
}
function optionLabel(options, value) {
return options.find((item) => item[0] === value)?.[1] || value || '-'
}
onMounted(loadTasks)
</script>
<template>
<PageHeading
eyebrow="订单与权益"
title="奖励任务"
description="配置奖励页任务、签到规则和发放记录,所有金币变动均写钱包流水。"
>
<el-button
v-if="activeTab === 'tasks' && can('reward:update')"
type="primary"
@click="openCreate"
>新建任务</el-button
>
<el-button
v-if="activeTab === 'grants' && can('reward:grant')"
type="primary"
@click="openGrant"
>人工补发</el-button
>
</PageHeading>
<section class="panel reward-panel">
<el-tabs v-model="activeTab" @tab-change="handleTab">
<el-tab-pane label="任务配置" name="tasks">
<div class="reward-toolbar">
<el-input
v-model="filters.keyword"
clearable
placeholder="任务编码 / 标题"
@keyup.enter="searchTasks"
/>
<el-select v-model="filters.type" clearable placeholder="全部类型" @change="searchTasks"
><el-option
v-for="item in typeOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]"
/></el-select>
<el-select
v-model="filters.sectionCode"
clearable
placeholder="全部分组"
@change="searchTasks"
><el-option
v-for="item in sectionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]"
/></el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="searchTasks"
><el-option label="已上线" :value="1" /><el-option label="已下线" :value="0"
/></el-select>
<el-button @click="searchTasks">查询</el-button
><el-button @click="resetFilters">重置</el-button>
</div>
<el-table
v-loading="tasks.loading"
:data="tasks.rows"
row-key="id"
empty-text="暂无奖励任务"
>
<el-table-column prop="code" label="任务编码" min-width="150" />
<el-table-column label="标题" min-width="180"
><template #default="{ row }"
><strong>{{ row.titleId }}</strong
><div class="sub-copy">{{ row.titleZh || row.titleEn }}</div></template
></el-table-column
>
<el-table-column label="类型" width="130"
><template #default="{ row }">{{
optionLabel(typeOptions, row.type)
}}</template></el-table-column
>
<el-table-column label="分组" width="110"
><template #default="{ row }">{{
optionLabel(sectionOptions, row.sectionCode)
}}</template></el-table-column
>
<el-table-column label="目标 / 奖励" width="130"
><template #default="{ row }"
>{{ formatCount(row.targetValue) }} / +{{ formatCount(row.rewardCoin) }}</template
></el-table-column
>
<el-table-column label="周期" width="100"
><template #default="{ row }">{{
optionLabel(cycleOptions, row.cycleType)
}}</template></el-table-column
>
<el-table-column label="状态" width="90"
><template #default="{ row }"
><el-tag :type="row.status === 1 ? 'success' : 'info'">{{
row.statusText
}}</el-tag></template
></el-table-column
>
<el-table-column label="操作" width="170" fixed="right"
><template #default="{ row }"
><el-button v-if="can('reward:update')" link type="primary" @click="openEdit(row)"
>编辑</el-button
><el-button
v-if="can('reward:update')"
link
:type="row.status === 1 ? 'danger' : 'success'"
@click="toggleTask(row)"
>{{ row.status === 1 ? '下线' : '上线' }}</el-button
></template
></el-table-column
>
</el-table>
<el-pagination
v-model:current-page="tasks.page"
v-model:page-size="tasks.size"
:total="tasks.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadTasks"
@size-change="searchTasks"
/>
</el-tab-pane>
<el-tab-pane label="签到规则" name="rules">
<div v-loading="rulesLoading" class="rule-grid"
><div v-for="day in rules" :key="day.dayIndex" class="rule-card"
><span>第 {{ day.dayIndex }} 天</span
><el-input-number v-model="day.rewardCoin" :min="0" /><small>金币</small></div
></div
>
<el-button
v-if="can('reward:checkin')"
type="primary"
:loading="rulesSaving"
@click="saveRules"
>保存签到规则</el-button
>
</el-tab-pane>
<el-tab-pane label="发放记录" name="grants">
<div class="reward-toolbar"
><el-input
v-model="grants.userId"
clearable
placeholder="用户 ID"
@keyup.enter="queryGrants"
/><el-select
v-model="grants.status"
clearable
placeholder="全部状态"
@change="queryGrants"
><el-option label="已发放" value="GRANTED" /><el-option
label="待处理"
value="PENDING" /><el-option label="失败" value="FAILED" /></el-select
><el-button @click="queryGrants">查询</el-button></div
>
<el-table
v-loading="grants.loading"
:data="grants.rows"
row-key="id"
empty-text="暂无发放记录"
>
<el-table-column prop="userId" label="用户 ID" min-width="160" /><el-table-column
prop="taskId"
label="任务 ID"
min-width="160"
/><el-table-column label="金币" width="100"
><template #default="{ row }">+{{ formatCount(row.amount) }}</template></el-table-column
><el-table-column prop="source" label="来源" width="90" /><el-table-column
prop="status"
label="状态"
width="100"
/><el-table-column
prop="idempotentKey"
label="幂等键"
min-width="240"
show-overflow-tooltip
/><el-table-column label="发放时间" width="180"
><template #default="{ row }">{{
dateCell(row.grantedAt || row.createdAt)
}}</template></el-table-column
>
</el-table>
<el-pagination
v-model:current-page="grants.page"
v-model:page-size="grants.size"
:total="grants.total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadGrants"
@size-change="queryGrants"
/>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog
v-model="dialogVisible"
:title="editingId ? '编辑奖励任务' : '新建奖励任务'"
width="780px"
>
<el-form label-position="top" class="task-form"
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="任务编码"
><el-input v-model="form.code" :disabled="Boolean(editingId)" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="任务类型"
><el-select v-model="form.type"
><el-option
v-for="item in typeOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="8"
><el-form-item label="分组"
><el-select v-model="form.sectionCode"
><el-option
v-for="item in sectionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="6"
><el-form-item label="目标值"
><el-input-number v-model="form.targetValue" :min="1" /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="奖励金币"
><el-input-number v-model="form.rewardCoin" :min="0" /></el-form-item></el-col
><el-col :span="6"
><el-form-item label="周期"
><el-select v-model="form.cycleType"
><el-option
v-for="item in cycleOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="6"
><el-form-item label="排序"
><el-input-number v-model="form.sort" :min="0" /></el-form-item></el-col
></el-row>
<el-divider content-position="left">多语言文案</el-divider
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语标题"
><el-input v-model="form.titleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文标题"><el-input v-model="form.titleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文标题"><el-input v-model="form.titleZh" /></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语说明"
><el-input v-model="form.subtitleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文说明"
><el-input v-model="form.subtitleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文说明"
><el-input v-model="form.subtitleZh" /></el-form-item></el-col
></el-row>
<el-row :gutter="16"
><el-col :span="8"
><el-form-item label="印尼语分组标题"
><el-input v-model="form.sectionTitleId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="英文分组标题"
><el-input v-model="form.sectionTitleEn" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="中文分组标题"
><el-input v-model="form.sectionTitleZh" /></el-form-item></el-col
></el-row>
<el-divider content-position="left">动作与扩展</el-divider
><el-row :gutter="16"
><el-col :span="8"
><el-form-item label="完成按钮"><el-input v-model="form.ctaId" /></el-form-item></el-col
><el-col :span="8"
><el-form-item label="动作"
><el-select v-model="form.actionType"
><el-option
v-for="item in actionOptions"
:key="item[0]"
:label="item[1]"
:value="item[0]" /></el-select></el-form-item></el-col
><el-col :span="8"
><el-form-item label="动作链接"
><el-input v-model="form.actionUrl" /></el-form-item></el-col></el-row
><el-form-item label="扩展配置 JSON"
><el-input v-model="form.configJson" type="textarea" :rows="3"
/></el-form-item>
</el-form>
<template #footer
><el-button @click="dialogVisible = false">取消</el-button
><el-button type="primary" :loading="saving" @click="saveTask">保存</el-button></template
>
</el-dialog>
<el-dialog v-model="grantVisible" title="人工补发奖励" width="520px"
><el-form label-position="top"
><el-form-item label="用户 ID"><el-input v-model="grantForm.userId" /></el-form-item
><el-form-item label="任务 ID(可选)"><el-input v-model="grantForm.taskId" /></el-form-item
><el-form-item label="补发金币"
><el-input-number v-model="grantForm.amount" :min="1" /></el-form-item
><el-form-item label="幂等键(重复补发时必须相同)"
><el-input v-model="grantForm.idempotentKey" /></el-form-item
><el-form-item label="原因"><el-input v-model="grantForm.reason" /></el-form-item></el-form
><template #footer
><el-button @click="grantVisible = false">取消</el-button
><el-button type="primary" :loading="grantSaving" @click="confirmGrant"
>确认补发</el-button
></template
></el-dialog
>
</template>
<style scoped>
.reward-panel {
padding: 20px;
}
.reward-toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.reward-toolbar .el-input,
.reward-toolbar .el-select {
width: 180px;
}
.sub-copy {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-top: 4px;
}
.rule-grid {
display: grid;
grid-template-columns: repeat(7, minmax(100px, 1fr));
gap: 12px;
margin: 8px 0 20px;
}
.rule-card {
display: grid;
gap: 8px;
padding: 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
background: var(--el-fill-color-lighter);
}
.rule-card small {
color: var(--el-text-color-secondary);
}
.task-form :deep(.el-select),
.task-form :deep(.el-input-number) {
width: 100%;
}
.el-pagination {
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 1100px) {
.rule-grid {
grid-template-columns: repeat(4, 1fr);
}
}
</style>