1288 lines
51 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.

<template>
<div class="app-container aslan-gift-challenge">
<div class="filter-container">
<el-button
v-if="canEdit"
class="filter-item"
type="primary"
icon="el-icon-plus"
@click="openCreate"
>
新建活动
</el-button>
<el-button
class="filter-item"
icon="el-icon-refresh"
:loading="listLoading"
@click="loadActivities"
>
刷新
</el-button>
<el-alert
class="page-tip"
type="info"
:closable="false"
show-icon
title="积分从活动开始时间起按 1 金币 = 1 积分累计;普通幸运礼物与超级幸运礼物由服务端强制排除,不参与榜单或送礼任务。"
/>
</div>
<el-table v-loading="listLoading" :data="activities" border fit highlight-current-row>
<el-table-column label="活动" min-width="230">
<template slot-scope="scope">
<div class="primary-text">{{ scope.row.activityName }}</div>
<div class="secondary-text">{{ scope.row.activityCode }}</div>
<div class="secondary-text">ID: {{ scope.row.id }}</div>
</template>
</el-table-column>
<el-table-column label="平台 / 时区" min-width="140" align="center">
<template slot-scope="scope">
<div>{{ scope.row.sysOrigin }}</div>
<div class="secondary-text">{{ scope.row.timeZone || 'Asia/Riyadh' }}</div>
</template>
</el-table-column>
<el-table-column label="活动时间" min-width="210" align="center">
<template slot-scope="scope">
<div>{{ formatTime(scope.row.startTime) }}</div>
<div>{{ formatTime(scope.row.endTime) }}</div>
</template>
</el-table-column>
<el-table-column label="榜单配置" min-width="155" align="center">
<template slot-scope="scope">
<div>展示 Top {{ scope.row.displayTopN || 100 }}</div>
<div class="secondary-text">日榜等待 {{ scope.row.dailySettlementDelayMinutes || 0 }} 分钟</div>
<div class="secondary-text">总榜等待 {{ scope.row.overallSettlementDelayMinutes || 0 }} 分钟</div>
</template>
</el-table-column>
<el-table-column label="状态" min-width="150" align="center">
<template slot-scope="scope">
<el-tag :type="activityStatusTag(activityStatus(scope.row))">
{{ activityStatusText(activityStatus(scope.row)) }}
</el-tag>
<div class="secondary-text">总榜结算:{{ settlementStatusText(scope.row.overallSettlementStatus) }}</div>
<div class="secondary-text">可结算:{{ formatTime(scope.row.overallSettlementTime) }}</div>
</template>
</el-table-column>
<el-table-column label="启用" width="90" align="center">
<template slot-scope="scope">
<el-switch
v-model="scope.row.enabled"
:disabled="!canEnable || hasStarted(scope.row)"
@change="changeEnabled(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="245" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" @click="openEdit(scope.row)">
{{ canEdit ? '配置' : '查看配置' }}
</el-button>
<el-button type="text" @click="openRanking(scope.row)">查看榜单</el-button>
<el-button type="text" @click="openSettlements(scope.row)">结算记录</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
:title="form.id ? '编辑 Aslan 礼物挑战' : '新建 Aslan 礼物挑战'"
:visible.sync="editorVisible"
width="1240px"
top="3vh"
:close-on-click-modal="false"
@closed="resetEditor"
>
<div v-loading="editorLoading" class="editor-body">
<el-alert
v-if="startedLocked"
type="warning"
:closable="false"
show-icon
title="活动已经开始:仅名称和说明可修改;时间、榜单容量、结算窗口、任务和奖励均被服务端锁定。"
/>
<div class="section-title">基础活动配置</div>
<el-form ref="activityForm" :model="form" :rules="formRules" label-width="145px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="活动编码" prop="activityCode">
<el-input
v-model.trim="form.activityCode"
maxlength="64"
show-word-limit
:disabled="editorReadOnly"
placeholder="例如 ASLAN_GIFT_CHALLENGE_S1"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="活动名称" prop="activityName">
<el-input
v-model.trim="form.activityName"
maxlength="128"
show-word-limit
:disabled="!canEdit"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属平台" prop="sysOrigin">
<el-input v-model="form.sysOrigin" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="活动时区" prop="timeZone">
<el-select
v-model="form.timeZone"
filterable
allow-create
default-first-option
style="width: 100%"
:disabled="editorReadOnly"
placeholder="IANA 时区,例如 Asia/Riyadh"
>
<el-option v-for="item in timeZoneOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="活动时间" prop="timeRange">
<el-date-picker
v-model="form.timeRange"
type="datetimerange"
value-format="timestamp"
style="width: 100%"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
:disabled="editorReadOnly"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="榜单展示 TopN" prop="displayTopN">
<el-input-number
v-model="form.displayTopN"
:min="1"
:max="500"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="editorReadOnly"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="日榜晚到窗口" prop="dailySettlementDelayMinutes">
<el-input-number
v-model="form.dailySettlementDelayMinutes"
:min="0"
:max="1440"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="editorReadOnly"
/>
<div class="form-help">每日结束后继续接收异步礼物流水的分钟数。</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="总榜晚到窗口" prop="overallSettlementDelayMinutes">
<el-input-number
v-model="form.overallSettlementDelayMinutes"
:min="0"
:max="1440"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="editorReadOnly"
/>
<div class="form-help">活动结束后等待异步流水,再冻结活动总榜。</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="积分比例">
<el-input value="1 金币 = 1 积分(服务端固定)" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排除礼物">
<el-input value="普通幸运礼物、超级幸运礼物(服务端强制)" disabled />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="活动说明">
<el-input
v-model.trim="form.activityDesc"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
:disabled="!canEdit"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="section-title">每日任务固定 3 个槽位</div>
<el-alert
type="info"
:closable="false"
show-icon
title="任务固定为 1 个每日进入页面和 2 个累计送礼金币档位;第二档门槛必须高于第一档,运营只配置展示文案、门槛和奖励。"
/>
<el-table :data="form.tasks" border class="config-table">
<el-table-column label="槽位" width="58" align="center">
<template slot-scope="scope">{{ scope.$index + 1 }}</template>
</el-table-column>
<el-table-column label="启用" width="70" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.enabled" disabled />
</template>
</el-table-column>
<el-table-column label="任务编码" min-width="165">
<template slot-scope="scope">
<span>{{ scope.row.taskCode }}</span>
</template>
</el-table-column>
<el-table-column label="任务类型" min-width="170">
<template slot-scope="scope">
<el-tag size="small">{{ scope.row.taskType === 'ENTER_PAGE' ? '每日进入活动页面' : '每日累计送礼金币' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="任务标题" min-width="180">
<template slot-scope="scope">
<el-input
v-model.trim="scope.row.taskTitle"
maxlength="128"
:disabled="editorReadOnly"
placeholder="H5 展示标题"
/>
</template>
</el-table-column>
<el-table-column label="目标值" min-width="120">
<template slot-scope="scope">
<el-input-number
v-model="scope.row.targetValue"
:min="1"
:max="999999999999"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="editorReadOnly || scope.row.taskType === 'ENTER_PAGE'"
/>
</template>
</el-table-column>
<el-table-column label="排序" width="95">
<template slot-scope="scope">
<span>{{ scope.row.sortOrder }}</span>
</template>
</el-table-column>
<el-table-column label="奖励资源组" min-width="210">
<template slot-scope="scope">
<el-button type="text" :disabled="editorReadOnly" @click="selectResourceGroup('task', scope.$index)">
{{ scope.row.resourceGroupId ? `资源组 ${scope.row.resourceGroupId}` : '选择资源组' }}
</el-button>
<props-row v-if="scope.row.rewards && scope.row.rewards.length" :list="scope.row.rewards" />
</template>
</el-table-column>
</el-table>
<div class="task-desc-grid">
<div v-for="(task, index) in form.tasks" :key="`desc-${index}`" class="task-desc-item">
<div class="task-desc-label">槽位 {{ index + 1 }} 描述</div>
<el-input
v-model.trim="task.taskDesc"
maxlength="500"
show-word-limit
:disabled="editorReadOnly"
placeholder="选填H5 展示说明"
/>
</div>
</div>
<div class="section-title rank-title-row">
<span>日榜奖励区间</span>
<el-button type="primary" size="mini" icon="el-icon-plus" :disabled="editorReadOnly" @click="addRankReward('DAILY')">
新增日榜区间
</el-button>
</div>
<el-alert
type="warning"
:closable="false"
show-icon
title="同一周期内名次区间不能重叠,结束名次不能超过 TopN允许留空档但至少配置一档。"
/>
<el-table :data="form.dailyRankRewards" border class="config-table">
<el-table-column label="开始名次" width="125">
<template slot-scope="scope">
<el-input-number v-model="scope.row.startRank" :min="1" :max="form.displayTopN" :precision="0" controls-position="right" style="width: 100%" :disabled="editorReadOnly" />
</template>
</el-table-column>
<el-table-column label="结束名次" width="125">
<template slot-scope="scope">
<el-input-number v-model="scope.row.endRank" :min="1" :max="form.displayTopN" :precision="0" controls-position="right" style="width: 100%" :disabled="editorReadOnly" />
</template>
</el-table-column>
<el-table-column label="奖励名称" min-width="190">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.rewardName" maxlength="128" :disabled="editorReadOnly" placeholder="例如 日榜 Top 1" />
</template>
</el-table-column>
<el-table-column label="奖励资源组" min-width="310">
<template slot-scope="scope">
<el-button type="text" :disabled="editorReadOnly" @click="selectResourceGroup('daily', scope.$index)">
{{ scope.row.resourceGroupId ? `资源组 ${scope.row.resourceGroupId}` : '选择资源组' }}
</el-button>
<props-row v-if="scope.row.rewards && scope.row.rewards.length" :list="scope.row.rewards" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button type="text" class="danger-link" :disabled="editorReadOnly || form.dailyRankRewards.length <= 1" @click="removeRankReward('DAILY', scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="section-title rank-title-row">
<span>活动总榜奖励区间</span>
<el-button type="primary" size="mini" icon="el-icon-plus" :disabled="editorReadOnly" @click="addRankReward('OVERALL')">
新增总榜区间
</el-button>
</div>
<el-table :data="form.overallRankRewards" border class="config-table">
<el-table-column label="开始名次" width="125">
<template slot-scope="scope">
<el-input-number v-model="scope.row.startRank" :min="1" :max="form.displayTopN" :precision="0" controls-position="right" style="width: 100%" :disabled="editorReadOnly" />
</template>
</el-table-column>
<el-table-column label="结束名次" width="125">
<template slot-scope="scope">
<el-input-number v-model="scope.row.endRank" :min="1" :max="form.displayTopN" :precision="0" controls-position="right" style="width: 100%" :disabled="editorReadOnly" />
</template>
</el-table-column>
<el-table-column label="奖励名称" min-width="190">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.rewardName" maxlength="128" :disabled="editorReadOnly" placeholder="例如 总榜 Top 1" />
</template>
</el-table-column>
<el-table-column label="奖励资源组" min-width="310">
<template slot-scope="scope">
<el-button type="text" :disabled="editorReadOnly" @click="selectResourceGroup('overall', scope.$index)">
{{ scope.row.resourceGroupId ? `资源组 ${scope.row.resourceGroupId}` : '选择资源组' }}
</el-button>
<props-row v-if="scope.row.rewards && scope.row.rewards.length" :list="scope.row.rewards" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button type="text" class="danger-link" :disabled="editorReadOnly || form.overallRankRewards.length <= 1" @click="removeRankReward('OVERALL', scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<span slot="footer">
<el-button @click="editorVisible = false">关闭</el-button>
<el-button v-if="canEdit" type="primary" :loading="saveLoading" @click="submitActivity">
保存全部配置
</el-button>
</span>
</el-dialog>
<el-dialog
:title="`${selectedActivity.activityName || ''} - 排行榜`"
:visible.sync="rankingVisible"
width="930px"
top="6vh"
:close-on-click-modal="false"
>
<div class="dialog-actions">
<el-select v-model="rankingQuery.periodType" @change="rankingPeriodChanged">
<el-option label="活动总榜" value="OVERALL" />
<el-option label="日榜" value="DAILY" />
</el-select>
<el-date-picker
v-if="rankingQuery.periodType === 'DAILY'"
v-model="rankingQuery.statDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择活动日"
/>
<el-input-number v-model="rankingQuery.limit" :min="1" :max="500" :precision="0" controls-position="right" />
<el-button type="primary" icon="el-icon-search" :loading="rankingLoading" @click="loadRanking">查询</el-button>
<el-tag :type="rankingResult.settled ? 'success' : 'info'">
{{ rankingResult.settled ? '已生成结算快照' : '未结算' }}
</el-tag>
</div>
<el-table v-loading="rankingLoading" :data="rankingResult.entries || []" border max-height="560">
<el-table-column label="名次" width="80" align="center" prop="rank" />
<el-table-column label="用户 ID" min-width="165" prop="userId" />
<el-table-column label="账号" min-width="125" prop="account" />
<el-table-column label="昵称" min-width="160" show-overflow-tooltip prop="nickname" />
<el-table-column label="积分" min-width="140" align="right">
<template slot-scope="scope">{{ formatNumber(scope.row.score) }}</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog
:title="`${selectedActivity.activityName || ''} - 结算记录`"
:visible.sync="settlementsVisible"
width="1240px"
top="4vh"
:close-on-click-modal="false"
>
<div class="dialog-actions settlement-actions">
<el-select v-model="settlementQuery.periodType" clearable placeholder="全部周期" @change="settlementPeriodChanged">
<el-option label="活动总榜" value="OVERALL" />
<el-option label="日榜" value="DAILY" />
</el-select>
<el-date-picker
v-if="settlementQuery.periodType === 'DAILY'"
v-model="settlementQuery.statDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择活动日"
/>
<el-select v-model="settlementQuery.deliveryStatus" clearable placeholder="全部发放状态">
<el-option label="待发放" value="PENDING" />
<el-option label="发放中" value="PROCESSING" />
<el-option label="成功" value="SUCCESS" />
<el-option label="结果未知(需核账)" value="UNKNOWN" />
</el-select>
<el-input-number v-model="settlementQuery.limit" :min="1" :max="500" :precision="0" controls-position="right" />
<el-button icon="el-icon-search" :loading="settlementsLoading" @click="loadSettlements">查询</el-button>
<el-button
v-if="canSettle"
type="danger"
icon="el-icon-s-check"
:loading="settleLoading"
:disabled="!settlementQuery.periodType"
@click="manualSettle"
>
手动结算当前周期
</el-button>
</div>
<el-alert
type="warning"
:closable="false"
show-icon
title="冻结榜单前先确认 ASLAN_GIFT_CHALLENGE_GIFT 消费组无不可接受积压UNKNOWN 表示奖励可能已到账,只有核账确认未到账才会开放该单项补发。"
/>
<el-table v-loading="settlementsLoading" :data="settlementRecords" border max-height="580" class="settlement-table">
<el-table-column type="expand" width="50">
<template slot-scope="scope">
<el-table :data="scope.row.deliveryItems || []" border size="mini" class="delivery-item-table">
<el-table-column label="奖励项 ID" min-width="150" prop="id" />
<el-table-column label="类型" min-width="135">
<template slot-scope="itemScope">
{{ itemScope.row.rewardType }} / {{ itemScope.row.detailType || '-' }}
</template>
</el-table-column>
<el-table-column label="内容" min-width="150" show-overflow-tooltip prop="content" />
<el-table-column label="数量" width="75" align="right" prop="quantity" />
<el-table-column label="状态" width="105" align="center">
<template slot-scope="itemScope">
<el-tag :type="deliveryStatusTag(itemScope.row.deliveryStatus)">{{ itemScope.row.deliveryStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态说明" min-width="190" show-overflow-tooltip prop="failureReason" />
<el-table-column label="UNKNOWN 核账" min-width="190" align="center" fixed="right">
<template slot-scope="itemScope">
<template v-if="itemScope.row.deliveryStatus === 'UNKNOWN' && canReconcile">
<el-button type="text" :loading="resolvingDeliveryItemId === itemScope.row.id" @click="resolveUnknownDeliveryItem(itemScope.row, true)">已到账</el-button>
<el-button type="text" class="danger-link" :loading="resolvingDeliveryItemId === itemScope.row.id" @click="resolveUnknownDeliveryItem(itemScope.row, false)">未到账并补发</el-button>
</template>
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column label="周期" width="90" align="center">
<template slot-scope="scope">{{ periodText(scope.row.periodType) }}</template>
</el-table-column>
<el-table-column label="统计日" width="115" align="center">
<template slot-scope="scope">{{ scope.row.statDate || '-' }}</template>
</el-table-column>
<el-table-column label="名次" width="70" align="center" prop="rank" />
<el-table-column label="用户 ID" min-width="155" prop="userId" />
<el-table-column label="积分" min-width="110" align="right">
<template slot-scope="scope">{{ formatNumber(scope.row.score) }}</template>
</el-table-column>
<el-table-column label="资源组" min-width="145" prop="resourceGroupId" />
<el-table-column label="状态" width="105" align="center">
<template slot-scope="scope">
<el-tag :type="deliveryStatusTag(scope.row.deliveryStatus)">{{ scope.row.deliveryStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column label="补发次数" width="90" align="center" prop="retryCount" />
<el-table-column label="状态说明" min-width="180" show-overflow-tooltip prop="failureReason" />
</el-table>
</el-dialog>
<associated-resources
v-if="resourceSelectorVisible"
:sys-origin="form.sysOrigin"
@select="applyResourceGroup"
@close="closeResourceSelector"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import AssociatedResources from '@/components/data/AssociatedResources'
import PropsRow from '@/components/data/PropsRow'
import {
getAslanGiftChallengeDetail,
getAslanGiftChallengeRanking,
listAslanGiftChallengeActivities,
listAslanGiftChallengeRankRewards,
listAslanGiftChallengeSettlementRecords,
listAslanGiftChallengeTasks,
resolveAslanGiftChallengeUnknownDeliveryItem,
saveAslanGiftChallengeActivity,
setAslanGiftChallengeEnabled,
settleAslanGiftChallengeActivity
} from '@/api/aslan-gift-challenge'
const PERIOD_DAILY = 'DAILY'
const PERIOD_OVERALL = 'OVERALL'
const TASK_ENTER_PAGE = 'ENTER_PAGE'
const TASK_SEND_GIFT_GOLD = 'SEND_GIFT_GOLD'
export default {
name: 'ActivityGiftChallenge',
components: { AssociatedResources, PropsRow },
data() {
return {
activities: [],
listLoading: false,
editorVisible: false,
editorLoading: false,
saveLoading: false,
serverTime: 0,
originalStartTime: 0,
backendActivityStatus: '',
form: this.emptyForm(),
formRules: {
activityCode: [{ required: true, message: '请输入活动编码', trigger: 'blur' }],
activityName: [{ required: true, message: '请输入活动名称', trigger: 'blur' }],
sysOrigin: [{ required: true, message: '所属平台不能为空', trigger: 'change' }],
timeZone: [{ required: true, message: '请输入活动时区', trigger: 'change' }],
timeRange: [{ type: 'array', required: true, len: 2, message: '请选择完整活动时间', trigger: 'change' }],
displayTopN: [{ required: true, message: '请输入榜单展示数量', trigger: 'blur' }],
dailySettlementDelayMinutes: [{ required: true, message: '请输入日榜晚到窗口', trigger: 'blur' }],
overallSettlementDelayMinutes: [{ required: true, message: '请输入总榜晚到窗口', trigger: 'blur' }]
},
timeZoneOptions: ['Asia/Riyadh', 'Asia/Shanghai', 'Europe/Istanbul', 'UTC'],
resourceSelectorVisible: false,
resourceSelection: null,
selectedActivity: {},
rankingVisible: false,
rankingLoading: false,
rankingQuery: {
periodType: PERIOD_OVERALL,
statDate: '',
limit: 100
},
rankingResult: {
settled: false,
entries: []
},
settlementsVisible: false,
settlementsLoading: false,
settleLoading: false,
settlementQuery: {
periodType: PERIOD_OVERALL,
statDate: '',
deliveryStatus: '',
limit: 100
},
settlementRecords: [],
resolvingDeliveryItemId: ''
}
},
computed: {
...mapGetters(['buttonPermissions']),
canEdit() {
return this.hasButtonPermission('activity:gift-challenge:edit')
},
canEnable() {
return this.hasButtonPermission('activity:gift-challenge:enable')
},
canSettle() {
return this.hasButtonPermission('activity:gift-challenge:settle')
},
canReconcile() {
return this.hasButtonPermission('activity:gift-challenge:reconcile')
},
startedLocked() {
if (!this.form.id) {
return false
}
// 后端返回的活动状态和 serverTime 是锁定判断基准,不能依赖运营电脑的本地时间绕开规则冻结。
return ['ACTIVE', 'ENDED'].indexOf(this.backendActivityStatus) >= 0 ||
(this.serverTime > 0 && this.originalStartTime > 0 && this.serverTime >= this.originalStartTime)
},
editorReadOnly() {
return !this.canEdit || this.startedLocked
}
},
created() {
this.loadActivities()
},
methods: {
emptyForm() {
return {
id: '',
activityCode: '',
activityName: '',
activityDesc: '',
sysOrigin: 'ATYOU',
timeZone: 'Asia/Riyadh',
timeRange: [],
dailySettlementDelayMinutes: 30,
overallSettlementDelayMinutes: 30,
displayTopN: 100,
enabled: false,
version: null,
tasks: this.emptyTasks(),
dailyRankRewards: [this.emptyRankReward(PERIOD_DAILY, 1)],
overallRankRewards: [this.emptyRankReward(PERIOD_OVERALL, 1)]
}
},
emptyTasks() {
// 产品固定一个进入页任务和两档送礼任务;编码、类型和排序不是运营配置项。
return [
this.emptyTask('daily_enter', TASK_ENTER_PAGE, '每日进入活动页面', 1, 1),
this.emptyTask('daily_gift_1', TASK_SEND_GIFT_GOLD, '每日累计送礼任务一', 1, 2),
this.emptyTask('daily_gift_2', TASK_SEND_GIFT_GOLD, '每日累计送礼任务二', 1, 3)
]
},
emptyTask(taskCode, taskType, taskTitle, targetValue, sortOrder) {
return {
id: '',
activityId: '',
taskCode,
taskType,
taskTitle,
taskDesc: '',
targetValue,
resourceGroupId: '',
enabled: true,
sortOrder,
rewards: []
}
},
emptyRankReward(periodType, startRank) {
return {
id: '',
activityId: '',
periodType,
startRank,
endRank: startRank,
resourceGroupId: '',
rewardName: '',
rewards: []
}
},
hasButtonPermission(alias) {
return Array.isArray(this.buttonPermissions) && this.buttonPermissions.indexOf(alias) >= 0
},
loadActivities() {
this.listLoading = true
listAslanGiftChallengeActivities().then(res => {
this.activities = Array.isArray(res.body) ? res.body : []
}).finally(() => {
this.listLoading = false
})
},
openCreate() {
this.resetEditor()
this.editorVisible = true
},
openEdit(row) {
this.resetEditor()
this.editorVisible = true
this.editorLoading = true
// 详情、任务和排行奖励分别走已发布的管理端点;即使以后详情做裁剪,配置页仍不会丢失子配置。
Promise.all([
getAslanGiftChallengeDetail(row.id),
listAslanGiftChallengeTasks(row.id),
listAslanGiftChallengeRankRewards(row.id)
]).then(results => {
const detail = results[0].body || {}
const activity = detail.activity || {}
const tasks = Array.isArray(results[1].body) ? results[1].body : (detail.tasks || [])
const rewards = Array.isArray(results[2].body) ? results[2].body : (detail.rankRewards || [])
this.form = Object.assign(this.emptyForm(), activity, {
sysOrigin: 'ATYOU',
timeZone: activity.timeZone || 'Asia/Riyadh',
dailySettlementDelayMinutes: this.numberOrDefault(activity.dailySettlementDelayMinutes, 30),
overallSettlementDelayMinutes: this.numberOrDefault(activity.overallSettlementDelayMinutes, 30),
displayTopN: this.numberOrDefault(activity.displayTopN, 100),
timeRange: [activity.startTime, activity.endTime],
tasks: this.normalizeTasks(tasks),
dailyRankRewards: this.normalizeRankRewards(rewards, PERIOD_DAILY),
overallRankRewards: this.normalizeRankRewards(rewards, PERIOD_OVERALL)
})
this.serverTime = Number(detail.serverTime || 0)
this.originalStartTime = Number(activity.startTime || 0)
this.backendActivityStatus = detail.activityStatus || ''
}).finally(() => {
this.editorLoading = false
})
},
normalizeTasks(tasks) {
const defaults = this.emptyTasks()
if (!Array.isArray(tasks) || tasks.length === 0) {
return defaults
}
return tasks.slice().sort((left, right) => Number(left.sortOrder) - Number(right.sortOrder))
.slice(0, 3).map((item, index) => Object.assign({}, defaults[index], item, {
taskCode: defaults[index].taskCode,
taskType: defaults[index].taskType,
sortOrder: defaults[index].sortOrder,
targetValue: index === 0 ? 1 : Number(item.targetValue || defaults[index].targetValue),
enabled: true,
rewards: item.rewardItems || item.rewards || []
})).concat(defaults.slice(Math.min(tasks.length, 3)))
},
normalizeRankRewards(rewards, periodType) {
const normalized = (Array.isArray(rewards) ? rewards : []).filter(item => item.periodType === periodType)
.sort((left, right) => Number(left.startRank) - Number(right.startRank))
.map(item => Object.assign({}, item, {
rewards: item.rewardItems || item.rewards || []
}))
return normalized.length > 0 ? normalized : [this.emptyRankReward(periodType, 1)]
},
numberOrDefault(value, defaultValue) {
return value === null || value === undefined ? defaultValue : Number(value)
},
resetEditor() {
this.form = this.emptyForm()
this.serverTime = 0
this.originalStartTime = 0
this.backendActivityStatus = ''
this.resourceSelectorVisible = false
this.resourceSelection = null
this.$nextTick(() => {
if (this.$refs.activityForm) {
this.$refs.activityForm.clearValidate()
}
})
},
submitActivity() {
this.$refs.activityForm.validate(valid => {
if (!valid || !this.validateConfiguration()) {
return
}
const payload = this.buildSavePayload()
this.saveLoading = true
// 新建或开始前编辑采用聚合保存,基础配置、三个任务和两类排行奖励由后端事务一次提交,避免半套配置被启用。
saveAslanGiftChallengeActivity(payload).then(() => {
this.$message.success('礼物挑战配置已保存')
this.editorVisible = false
this.loadActivities()
}).finally(() => {
this.saveLoading = false
})
})
},
validateConfiguration() {
const timeRange = this.form.timeRange || []
if (timeRange.length !== 2 || Number(timeRange[0]) >= Number(timeRange[1])) {
this.$message.error('活动结束时间必须晚于开始时间')
return false
}
if (this.form.sysOrigin !== 'ATYOU') {
this.$message.error('Aslan 礼物挑战只允许配置 ATYOU 平台')
return false
}
if (!this.validInteger(this.form.displayTopN, 1, 500)) {
this.$message.error('榜单 TopN 必须是 1 到 500 的整数')
return false
}
if (!this.validInteger(this.form.dailySettlementDelayMinutes, 0, 1440) ||
!this.validInteger(this.form.overallSettlementDelayMinutes, 0, 1440)) {
this.$message.error('日榜和总榜晚到窗口必须是 0 到 1440 的整数分钟')
return false
}
// 统计开始后后端要求 tasks/rankRewards 完全不出现在请求中;这里只校验仍允许修改的基础字段。
if (this.startedLocked) {
return true
}
if (!this.validateTasks()) {
return false
}
return this.validateRankRewards(PERIOD_DAILY, this.form.dailyRankRewards) &&
this.validateRankRewards(PERIOD_OVERALL, this.form.overallRankRewards)
},
validateTasks() {
const tasks = this.form.tasks || []
if (tasks.length !== 3) {
this.$message.error('任务必须且只能保留 3 个槽位')
return false
}
const codes = new Set()
const sortOrders = new Set()
let enterCount = 0
let giftCount = 0
const fixedTasks = this.emptyTasks()
for (let index = 0; index < tasks.length; index++) {
const task = tasks[index]
const code = String(task.taskCode || '').trim()
const title = String(task.taskTitle || '').trim()
if (!/^[A-Za-z0-9_-]{1,64}$/.test(code) || codes.has(code) ||
code !== fixedTasks[index].taskCode || task.taskType !== fixedTasks[index].taskType) {
this.$message.error(`任务槽位 ${index + 1} 的固定编码或类型无效`)
return false
}
codes.add(code)
if (!title || title.length > 128 || String(task.taskDesc || '').length > 500) {
this.$message.error(`请检查任务槽位 ${index + 1} 的标题和描述长度`)
return false
}
if (!this.validInteger(task.targetValue, 1, 999999999999)) {
this.$message.error(`任务槽位 ${index + 1} 的目标值必须是正整数`)
return false
}
if (Number(task.sortOrder) !== fixedTasks[index].sortOrder ||
sortOrders.has(Number(task.sortOrder))) {
this.$message.error(`任务槽位 ${index + 1} 的固定排序无效`)
return false
}
sortOrders.add(Number(task.sortOrder))
if (!task.enabled || !task.resourceGroupId) {
this.$message.error(`任务槽位 ${index + 1} 必须启用并选择奖励资源组`)
return false
}
if (task.taskType === TASK_ENTER_PAGE) {
enterCount++
if (Number(task.targetValue) !== 1) {
this.$message.error('进入页面任务的目标值必须为 1')
return false
}
} else {
giftCount++
}
}
if (enterCount !== 1 || giftCount !== 2) {
this.$message.error('任务必须固定为 1 个每日进入页面和 2 个累计送礼金币档位')
return false
}
const giftTasks = tasks
.filter(item => item.taskType === TASK_SEND_GIFT_GOLD)
.sort((left, right) => Number(left.sortOrder) - Number(right.sortOrder))
// 两档按固定槽位递增,提前在 console 阻止“同一次送礼同时完成两个重复档位”的配置。
if (Number(giftTasks[0].targetValue) >= Number(giftTasks[1].targetValue)) {
this.$message.error('第二档累计送礼金币门槛必须高于第一档')
return false
}
return true
},
validateRankRewards(periodType, rewards) {
const periodName = periodType === PERIOD_DAILY ? '日榜' : '总榜'
if (!Array.isArray(rewards) || rewards.length === 0) {
this.$message.error(`${periodName}至少配置一个奖励区间`)
return false
}
const ordered = rewards.slice().sort((left, right) => Number(left.startRank) - Number(right.startRank))
let previousEnd = 0
for (let index = 0; index < ordered.length; index++) {
const reward = ordered[index]
const start = Number(reward.startRank)
const end = Number(reward.endRank)
if (!this.validInteger(start, 1, Number(this.form.displayTopN)) ||
!this.validInteger(end, 1, Number(this.form.displayTopN)) || start > end) {
this.$message.error(`${periodName}${index + 1} 档名次范围无效或超过 TopN`)
return false
}
if (start <= previousEnd) {
this.$message.error(`${periodName}奖励区间存在重叠`)
return false
}
if (!reward.resourceGroupId || String(reward.rewardName || '').length > 128) {
this.$message.error(`${periodName}${index + 1} 档必须选择奖励资源组,并检查奖励名称长度`)
return false
}
previousEnd = end
}
return true
},
validInteger(value, min, max) {
const number = Number(value)
return Number.isSafeInteger(number) && number >= min && number <= max
},
buildSavePayload() {
const payload = {
id: this.form.id || null,
activityCode: this.form.activityCode.trim(),
activityName: this.form.activityName.trim(),
activityDesc: this.form.activityDesc,
sysOrigin: 'ATYOU',
timeZone: this.form.timeZone,
startTime: Number(this.form.timeRange[0]),
endTime: Number(this.form.timeRange[1]),
dailySettlementDelayMinutes: Number(this.form.dailySettlementDelayMinutes),
overallSettlementDelayMinutes: Number(this.form.overallSettlementDelayMinutes),
displayTopN: Number(this.form.displayTopN),
enabled: Boolean(this.form.enabled),
version: this.form.id ? Number(this.form.version) : null
}
if (!this.startedLocked) {
payload.tasks = this.form.tasks.map(item => ({
id: item.id || null,
activityId: this.form.id || null,
taskCode: item.taskCode.trim(),
taskType: item.taskType,
taskTitle: item.taskTitle.trim(),
taskDesc: item.taskDesc,
targetValue: Number(item.targetValue),
resourceGroupId: String(item.resourceGroupId),
enabled: true,
sortOrder: Number(item.sortOrder)
}))
payload.rankRewards = this.form.dailyRankRewards.concat(this.form.overallRankRewards).map(item => ({
id: item.id || null,
activityId: this.form.id || null,
periodType: item.periodType,
startRank: Number(item.startRank),
endRank: Number(item.endRank),
resourceGroupId: String(item.resourceGroupId),
rewardName: item.rewardName
}))
}
return payload
},
addRankReward(periodType) {
const list = this.rankRewardList(periodType)
const lastEnd = list.reduce((max, item) => Math.max(max, Number(item.endRank || 0)), 0)
const nextRank = lastEnd + 1
if (nextRank > Number(this.form.displayTopN)) {
this.$message.warning('当前区间已到达 TopN请先调整已有区间或提高 TopN')
return
}
list.push(this.emptyRankReward(periodType, nextRank))
},
removeRankReward(periodType, index) {
const list = this.rankRewardList(periodType)
if (list.length <= 1) {
this.$message.warning('每个排行周期至少保留一个奖励区间')
return
}
list.splice(index, 1)
},
rankRewardList(periodType) {
return periodType === PERIOD_DAILY ? this.form.dailyRankRewards : this.form.overallRankRewards
},
selectResourceGroup(scope, index) {
if (this.editorReadOnly) {
return
}
this.resourceSelection = { scope, index }
this.resourceSelectorVisible = true
},
applyResourceGroup(source) {
if (!this.resourceSelection || !source) {
return
}
let row
if (this.resourceSelection.scope === 'task') {
row = this.form.tasks[this.resourceSelection.index]
} else if (this.resourceSelection.scope === 'daily') {
row = this.form.dailyRankRewards[this.resourceSelection.index]
} else {
row = this.form.overallRankRewards[this.resourceSelection.index]
}
if (!row) {
this.closeResourceSelector()
return
}
// 奖励组 Long ID 始终按字符串保存,防止浏览器 Number 截断 Snowflake ID 后配置到错误资源组。
row.resourceGroupId = String(source.id)
row.rewards = source.rewardConfigList || []
if (this.resourceSelection.scope !== 'task' && !row.rewardName) {
row.rewardName = source.name || ''
}
this.closeResourceSelector()
},
closeResourceSelector() {
this.resourceSelectorVisible = false
this.resourceSelection = null
},
changeEnabled(row) {
const enabled = Boolean(row.enabled)
const previous = !enabled
const action = enabled ? '启用' : '停用'
this.$confirm(`确认${action}活动「${row.activityName}」?活动开始后不可切换状态。`, '状态确认', {
type: 'warning'
}).then(() => setAslanGiftChallengeEnabled(row.id, enabled)).then(() => {
this.$message.success(`${action}成功`)
this.loadActivities()
}).catch(() => {
// 用户取消和接口失败都必须回滚 switch服务端仍是唯一状态真相。
row.enabled = previous
})
},
openRanking(row) {
this.selectedActivity = row
this.rankingQuery = {
periodType: PERIOD_OVERALL,
statDate: '',
limit: Number(row.displayTopN || 100)
}
this.rankingResult = { settled: false, entries: [] }
this.rankingVisible = true
this.loadRanking()
},
rankingPeriodChanged() {
// 活动日由配置时区决定,不能用运营电脑本地日期猜测;切到日榜后要求人工明确选择。
this.rankingQuery.statDate = ''
this.loadRanking()
},
loadRanking() {
if (!this.selectedActivity.id) {
return
}
if (this.rankingQuery.periodType === PERIOD_DAILY && !this.rankingQuery.statDate) {
this.$message.warning('查询日榜时请选择统计日')
return
}
this.rankingLoading = true
getAslanGiftChallengeRanking(this.selectedActivity.id, {
periodType: this.rankingQuery.periodType,
statDate: this.rankingQuery.periodType === PERIOD_DAILY ? this.rankingQuery.statDate : undefined,
limit: Number(this.rankingQuery.limit)
}).then(res => {
this.rankingResult = res.body || { settled: false, entries: [] }
}).finally(() => {
this.rankingLoading = false
})
},
openSettlements(row) {
this.selectedActivity = row
this.settlementQuery = {
periodType: PERIOD_OVERALL,
statDate: '',
deliveryStatus: '',
limit: 100
}
this.settlementRecords = []
this.settlementsVisible = true
this.loadSettlements()
},
settlementPeriodChanged() {
// 日榜结算会真实发奖,切换周期时清空旧日期,防止沿用另一时区下的“今天”。
this.settlementQuery.statDate = ''
this.loadSettlements()
},
loadSettlements() {
if (!this.selectedActivity.id) {
return
}
this.settlementsLoading = true
listAslanGiftChallengeSettlementRecords(this.selectedActivity.id, {
periodType: this.settlementQuery.periodType || undefined,
statDate: this.settlementQuery.periodType === PERIOD_DAILY ? this.settlementQuery.statDate || undefined : undefined,
deliveryStatus: this.settlementQuery.deliveryStatus || undefined,
limit: Number(this.settlementQuery.limit)
}).then(res => {
this.settlementRecords = Array.isArray(res.body) ? res.body : []
}).finally(() => {
this.settlementsLoading = false
})
},
manualSettle() {
const period = this.settlementQuery.periodType
const statDate = period === PERIOD_DAILY ? this.settlementQuery.statDate : ''
if (!period || (period === PERIOD_DAILY && !statDate)) {
this.$message.warning('手动结算日榜时必须选择统计日')
return
}
const periodName = period === PERIOD_DAILY ? `日榜 ${statDate}` : '活动总榜'
this.$confirm(`确认手动结算「${periodName}」?未到晚到窗口或已结算时服务端会拒绝。`, '结算确认', {
type: 'warning'
}).then(() => {
this.settleLoading = true
return settleAslanGiftChallengeActivity(this.selectedActivity.id, period, statDate)
}).then(() => {
this.$message.success('结算请求执行完成')
this.loadSettlements()
this.loadActivities()
}).finally(() => {
this.settleLoading = false
})
},
resolveUnknownDeliveryItem(item, delivered) {
const action = delivered ? '确认该奖励项实际已到账' : '确认未到账并开放补发'
this.$confirm(`${action}?此操作依赖人工核账结果,不可用作普通重试。`, 'UNKNOWN 核账确认', {
type: 'warning'
}).then(() => {
this.resolvingDeliveryItemId = item.id
return resolveAslanGiftChallengeUnknownDeliveryItem(item.id, delivered)
}).then(() => {
this.$message.success('核账结果已提交')
this.loadSettlements()
}).finally(() => {
this.resolvingDeliveryItemId = ''
})
},
activityStatus(row) {
const now = Date.now()
if (!row.enabled) {
return 'DISABLED'
}
if (now < Number(row.startTime || 0)) {
return 'PLANNED'
}
if (now <= Number(row.endTime || 0)) {
return 'ACTIVE'
}
return 'ENDED'
},
activityStatusText(status) {
return {
DISABLED: '未启用',
PLANNED: '待开始',
ACTIVE: '进行中',
ENDED: '已结束'
}[status] || status
},
activityStatusTag(status) {
return {
DISABLED: 'info',
PLANNED: 'warning',
ACTIVE: 'success',
ENDED: ''
}[status] || 'info'
},
settlementStatusText(status) {
return {
NOT_STARTED: '未开始',
PROCESSING: '处理中',
COMPLETED: '已完成',
RECONCILIATION_REQUIRED: '待核账'
}[status] || status || '未开始'
},
deliveryStatusTag(status) {
return {
SUCCESS: 'success',
UNKNOWN: 'warning',
PROCESSING: '',
PENDING: 'info'
}[status] || 'info'
},
periodText(period) {
return period === PERIOD_DAILY ? '日榜' : (period === PERIOD_OVERALL ? '总榜' : period)
},
hasStarted(row) {
return Number(row.startTime || 0) <= Date.now()
},
formatTime(value) {
if (!value) {
return '-'
}
const date = new Date(Number(value))
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString()
},
formatNumber(value) {
if (value === null || value === undefined || value === '') {
return '0'
}
const number = Number(value)
return Number.isFinite(number) ? number.toLocaleString() : String(value)
}
}
}
</script>
<style scoped lang="scss">
.aslan-gift-challenge {
.page-tip {
margin: 8px 0 18px;
}
.primary-text {
font-weight: 600;
}
.secondary-text,
.form-help {
margin-top: 4px;
color: #909399;
font-size: 12px;
line-height: 18px;
}
.editor-body {
max-height: 76vh;
padding-right: 8px;
overflow-y: auto;
}
.section-title {
margin: 24px 0 14px;
padding-left: 10px;
border-left: 4px solid #409eff;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.rank-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.config-table,
.settlement-table {
margin-top: 12px;
}
.task-desc-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 12px;
}
.task-desc-item {
padding: 10px;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.task-desc-label {
margin-bottom: 8px;
color: #606266;
font-size: 13px;
}
.dialog-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.delivery-item-table {
width: calc(100% - 30px);
margin: 4px 15px 12px;
}
.danger-link {
color: #f56c6c;
}
}
@media (max-width: 1100px) {
.aslan-gift-challenge .task-desc-grid {
grid-template-columns: 1fr;
}
}
</style>