1237 lines
48 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-game-king">
<div class="filter-container">
<el-button 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="消费金额、抽奖次数和排行榜均以后端金币流水为准;活动开始后,每次抽奖金币数、开始时间、平台及事件白名单不可修改。"
/>
</div>
<el-table v-loading="listLoading" :data="activities" border fit highlight-current-row>
<el-table-column label="活动" min-width="220">
<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="135" 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="205" 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="125" align="right">
<template slot-scope="scope">
每 {{ formatNumber(scope.row.coinPerDraw) }} 金币
</template>
</el-table-column>
<el-table-column label="精确事件白名单" min-width="220">
<template slot-scope="scope">
<el-tag
v-for="eventType in scope.row.eventTypes || []"
:key="eventType"
class="event-tag"
size="mini"
>
{{ eventType }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" min-width="130" align="center">
<template slot-scope="scope">
<el-tag :type="activityStatusTag(activityStatus(scope.row))">
{{ activityStatusText(activityStatus(scope.row)) }}
</el-tag>
<div class="settlement-status">结算:{{ settlementStatusText(scope.row.settlementStatus) }}</div>
<div class="secondary-text">可结算:{{ formatTime(scope.row.settlementTime) }}</div>
</template>
</el-table-column>
<el-table-column label="启用" width="85" align="center">
<template slot-scope="scope">
<el-switch
v-model="scope.row.enabled"
:disabled="hasStarted(scope.row)"
@change="changeEnabled(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="350" align="center" fixed="right">
<template slot-scope="scope">
<el-button type="text" @click="openEdit(scope.row)">配置</el-button>
<el-button type="text" @click="openDrawRecords(scope.row)">抽奖发放</el-button>
<el-button type="text" @click="openSettlements(scope.row)">结算记录</el-button>
<el-button
type="text"
:disabled="scope.row.settlementStatus !== 'NOT_STARTED'"
@click="openBackfill(scope.row)"
>
消费补数
</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
:title="form.id ? '编辑 Aslan 游戏王活动' : '新建 Aslan 游戏王活动'"
:visible.sync="editorVisible"
width="1180px"
top="4vh"
: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="135px">
<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="startedLocked"
placeholder="例如 ASLAN_GAME_KING_S2"
/>
</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 />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属平台" prop="sysOrigin">
<el-select v-model="form.sysOrigin" style="width: 100%" :disabled="startedLocked" filterable>
<el-option
v-for="item in permissionsSysOriginPlatforms"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="活动时区" prop="timeZone">
<el-select v-model="form.timeZone" style="width: 100%" filterable :disabled="startedLocked">
<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="startedLocked"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="每次抽奖金币数" prop="coinPerDraw">
<el-input-number
v-model="form.coinPerDraw"
:min="1"
:max="999999999999"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="startedLocked"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结算等待(分钟)" prop="settlementDelayMinutes">
<el-input-number
v-model="form.settlementDelayMinutes"
:min="0"
:max="1440"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="startedLocked"
/>
<div class="form-help">活动结束后继续等待钱包异步流水到达,再冻结最终榜单;默认 30 分钟。</div>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="精确事件白名单" prop="eventTypes">
<el-select
v-model="form.eventTypes"
multiple
filterable
allow-create
default-first-option
style="width: 100%"
:disabled="startedLocked"
placeholder="输入钱包流水的完整 eventType 后回车,例如 HOT_GAME_123不支持前缀和通配符"
/>
<div class="form-help">
这里只做精确等值匹配。第三方游戏通常包含游戏 ID请从真实金币支出流水复制完整 eventType避免把礼物或其他支出计入活动。
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排行口径">
<el-input value="消费金币榜TYCOON" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排行周期">
<el-input value="活动总榜OVERALL" 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="500" show-word-limit />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="section-title">抽奖奖项固定 7 个且必须全部启用</div>
<el-alert
type="info"
:closable="false"
show-icon
title="权重只表示相对概率;总权重必须大于 0。库存 -1 表示不限库存0 表示不可再抽中。"
/>
<el-table :data="form.prizes" border class="config-table">
<el-table-column label="顺序" width="70" align="center" prop="sortOrder" />
<el-table-column label="启用" width="75" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.enabled" :disabled="startedLocked" />
</template>
</el-table-column>
<el-table-column label="奖项名称" min-width="160">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.prizeName" maxlength="128" placeholder="必填" :disabled="startedLocked" />
</template>
</el-table-column>
<el-table-column label="奖项图片" min-width="175" align="center">
<template slot-scope="scope">
<el-image
v-if="startedLocked"
class="readonly-prize-image"
:src="scope.row.prizeImage"
fit="contain"
/>
<upload-image
v-else
v-model="scope.row.prizeImage"
file-dir="activity/game-king"
tips="建议透明 PNG"
/>
</template>
</el-table-column>
<el-table-column label="权重" min-width="120">
<template slot-scope="scope">
<el-input-number v-model="scope.row.weight" :min="1" :max="999999999" :precision="0" controls-position="right" :disabled="startedLocked" />
</template>
</el-table-column>
<el-table-column label="库存" min-width="120">
<template slot-scope="scope">
<el-input-number v-model="scope.row.stock" :min="-1" :max="999999999" :precision="0" controls-position="right" :disabled="startedLocked" />
</template>
</el-table-column>
<el-table-column label="奖励资源组" min-width="190">
<template slot-scope="scope">
<el-button type="text" :disabled="startedLocked" @click="selectResourceGroup('prize', 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="weight-summary">当前总权重{{ totalPrizeWeight }}</div>
<div class="section-title">排行榜结算奖励固定六档</div>
<el-alert
type="warning"
:closable="false"
show-icon
title="名次范围不可增删或修改;活动结束后按最终消费榜快照结算,失败记录可在结算记录中单独重试。"
/>
<el-table :data="form.rankRewards" border class="config-table">
<el-table-column label="名次范围" width="130" align="center">
<template slot-scope="scope">
<el-tag type="success">{{ rankRangeText(scope.row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="奖励名称" min-width="220">
<template slot-scope="scope">
<el-input v-model.trim="scope.row.rewardName" maxlength="128" placeholder="例如 Top 1 奖励" :disabled="startedLocked" />
</template>
</el-table-column>
<el-table-column label="奖励资源组" min-width="360">
<template slot-scope="scope">
<el-button type="text" :disabled="startedLocked" @click="selectResourceGroup('rank', 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>
<span slot="footer">
<el-button @click="editorVisible = false">取消</el-button>
<el-button type="primary" :loading="saveLoading" @click="submitActivity">保存全部配置</el-button>
</span>
</el-dialog>
<el-dialog
:title="`${selectedActivity.activityName || ''} - 结算记录`"
:visible.sync="settlementsVisible"
width="1120px"
top="6vh"
:close-on-click-modal="false"
>
<div class="dialog-actions">
<el-button icon="el-icon-refresh" :loading="settlementsLoading" @click="loadSettlements">刷新</el-button>
<el-button
type="danger"
icon="el-icon-s-check"
:disabled="!settlementReady(selectedActivity)"
:loading="settleLoading"
@click="manualSettle"
>
手工结算
</el-button>
<span class="form-help">到达结束时间 + 结算等待后才可执行UNKNOWN 必须按奖励项 ID 核账禁止直接重发</span>
</div>
<el-table v-loading="settlementsLoading" :data="settlementRecords" border max-height="570">
<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="145" prop="id" />
<el-table-column label="类型" min-width="115">
<template slot-scope="itemScope">
{{ itemScope.row.rewardType }} / {{ itemScope.row.detailType || '-' }}
</template>
</el-table-column>
<el-table-column label="内容" min-width="160" show-overflow-tooltip prop="content" />
<el-table-column label="数量" width="80" align="right" prop="quantity" />
<el-table-column label="状态" width="100" 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="重试" width="70" align="center" prop="retryCount" />
<el-table-column label="失败原因" min-width="180" show-overflow-tooltip prop="failureReason" />
<el-table-column label="发放时间" min-width="165" align="center">
<template slot-scope="itemScope">{{ formatTime(itemScope.row.deliverTime) }}</template>
</el-table-column>
<el-table-column label="UNKNOWN 核账" min-width="185" align="center" fixed="right">
<template slot-scope="itemScope">
<template v-if="itemScope.row.deliveryStatus === 'UNKNOWN'">
<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="70" align="center" prop="rank" />
<el-table-column label="用户 ID" min-width="160" prop="userId" />
<el-table-column label="消费金币" min-width="120" align="right">
<template slot-scope="scope">{{ formatNumber(scope.row.totalConsumed) }}</template>
</el-table-column>
<el-table-column label="资源组" min-width="145" prop="resourceGroupId" />
<el-table-column label="发放状态" width="110" 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="210" show-overflow-tooltip prop="failureReason" />
<el-table-column label="发放时间" min-width="165" align="center">
<template slot-scope="scope">{{ formatTime(scope.row.deliverTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template slot-scope="scope">
<el-button
v-if="scope.row.deliveryStatus === 'FAILED'"
type="text"
:loading="retryingRecordId === scope.row.id"
@click="retrySettlement(scope.row)"
>
重试
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog
:title="`${selectedActivity.activityName || ''} - 抽奖发放记录`"
:visible.sync="drawRecordsVisible"
width="1120px"
top="6vh"
:close-on-click-modal="false"
>
<div class="dialog-actions">
<el-select v-model="drawDeliveryStatus" clearable placeholder="全部发放状态" @change="loadDrawRecords">
<el-option label="待发放" value="PENDING" />
<el-option label="发放中" value="PROCESSING" />
<el-option label="成功" value="SUCCESS" />
<el-option label="失败" value="FAILED" />
<el-option label="结果未知(需核账)" value="UNKNOWN" />
</el-select>
<el-button icon="el-icon-refresh" :loading="drawRecordsLoading" @click="loadDrawRecords">刷新</el-button>
<span class="form-help"> FAILED 可重试UNKNOWN 表示调用结果不确定必须按奖励项 ID 核账页面不会自动重发</span>
</div>
<el-table v-loading="drawRecordsLoading" :data="drawRecords" border max-height="570">
<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="145" prop="id" />
<el-table-column label="类型" min-width="115">
<template slot-scope="itemScope">
{{ itemScope.row.rewardType }} / {{ itemScope.row.detailType || '-' }}
</template>
</el-table-column>
<el-table-column label="内容" min-width="160" show-overflow-tooltip prop="content" />
<el-table-column label="数量" width="80" align="right" prop="quantity" />
<el-table-column label="状态" width="100" 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="重试" width="70" align="center" prop="retryCount" />
<el-table-column label="失败原因" min-width="180" show-overflow-tooltip prop="failureReason" />
<el-table-column label="发放时间" min-width="165" align="center">
<template slot-scope="itemScope">{{ formatTime(itemScope.row.deliverTime) }}</template>
</el-table-column>
<el-table-column label="UNKNOWN 核账" min-width="185" align="center" fixed="right">
<template slot-scope="itemScope">
<template v-if="itemScope.row.deliveryStatus === 'UNKNOWN'">
<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="记录 ID" min-width="150" prop="id" />
<el-table-column label="用户 ID" min-width="150" prop="userId" />
<el-table-column label="奖项" min-width="150" prop="prizeName" />
<el-table-column label="资源组" min-width="145" prop="resourceGroupId" />
<el-table-column label="发放状态" width="110" 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="失败原因" min-width="210" show-overflow-tooltip prop="failureReason" />
<el-table-column label="抽奖时间" min-width="165" align="center">
<template slot-scope="scope">{{ formatTime(scope.row.drawTime) }}</template>
</el-table-column>
<el-table-column label="发放时间" min-width="165" align="center">
<template slot-scope="scope">{{ formatTime(scope.row.deliverTime) }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template slot-scope="scope">
<el-button
v-if="scope.row.deliveryStatus === 'FAILED'"
type="text"
:loading="retryingDrawRecordId === scope.row.id"
@click="retryDrawRecord(scope.row)"
>
重试
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog
:title="`${selectedActivity.activityName || ''} - 历史游戏流水补数`"
:visible.sync="backfillVisible"
width="620px"
:close-on-click-modal="false"
>
<el-alert
type="warning"
:closable="false"
show-icon
title="补数按收支类型、单个精确 eventType 和金币流水主键游标执行。每组条件必须分别补到 finished=true防止共用游标漏数据。"
/>
<el-form label-width="120px" class="backfill-form">
<el-form-item label="流水类型">
<el-select v-model="backfillForm.receiptType" style="width: 100%" @change="resetBackfillCursor">
<el-option label="游戏消费Tycoon / 抽奖次数)" :value="1" />
<el-option label="游戏中奖收入Victorious" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="精确 eventType">
<el-select
v-model="backfillForm.eventType"
style="width: 100%"
filterable
@change="resetBackfillCursor"
>
<el-option
v-for="item in selectedActivity.eventTypes || []"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="lastId 游标">
<el-input v-model.trim="backfillForm.lastId" placeholder="首次留空;后续自动回填 nextLastId" />
</el-form-item>
<el-form-item label="单批数量">
<el-input-number v-model="backfillForm.limit" :min="1" :max="500" :precision="0" />
</el-form-item>
</el-form>
<el-descriptions v-if="backfillResult" :column="2" border>
<el-descriptions-item label="扫描">{{ backfillResult.scanned }}</el-descriptions-item>
<el-descriptions-item label="计入">{{ backfillResult.accepted }}</el-descriptions-item>
<el-descriptions-item label="下一游标">{{ backfillResult.nextLastId || '-' }}</el-descriptions-item>
<el-descriptions-item label="完成">{{ backfillResult.finished ? '是' : '否' }}</el-descriptions-item>
</el-descriptions>
<span slot="footer">
<el-button @click="backfillVisible = false">关闭</el-button>
<el-button type="primary" :loading="backfillLoading" :disabled="backfillResult && backfillResult.finished" @click="runBackfill">
执行一批
</el-button>
</span>
</el-dialog>
<associated-resources
v-if="resourceSelectorVisible"
:sys-origin="form.sysOrigin"
@select="applyResourceGroup"
@close="resourceSelectorVisible = false"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import AssociatedResources from '@/components/data/AssociatedResources'
import PropsRow from '@/components/data/PropsRow'
import UploadImage from '@/components/upload-image'
import {
backfillAslanGameKingConsumption,
getAslanGameKingDetail,
listAslanGameKingActivities,
listAslanGameKingDrawRecords,
listAslanGameKingSettlementRecords,
resolveAslanGameKingUnknownDeliveryItem,
retryAslanGameKingDraw,
retryAslanGameKingSettlement,
saveAslanGameKingActivity,
setAslanGameKingEnabled,
settleAslanGameKingActivity
} from '@/api/aslan-game-king'
const REQUIRED_RANK_RANGES = [
[1, 1],
[2, 2],
[3, 3],
[4, 7],
[8, 10],
[11, 30]
]
export default {
name: 'ActivityGameKing',
components: { AssociatedResources, PropsRow, UploadImage },
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' }],
coinPerDraw: [{ required: true, message: '请输入每次抽奖金币数', trigger: 'blur' }],
settlementDelayMinutes: [{ required: true, message: '请输入结算等待分钟数', trigger: 'blur' }],
eventTypes: [{ type: 'array', required: true, min: 1, message: '至少配置一个精确 eventType', trigger: 'change' }]
},
timeZoneOptions: ['Asia/Riyadh', 'Asia/Shanghai', 'UTC'],
resourceSelectorVisible: false,
resourceSelection: null,
selectedActivity: {},
settlementsVisible: false,
settlementsLoading: false,
settleLoading: false,
settlementRecords: [],
retryingRecordId: '',
drawRecordsVisible: false,
drawRecordsLoading: false,
drawRecords: [],
drawDeliveryStatus: '',
retryingDrawRecordId: '',
resolvingDeliveryItemId: '',
backfillVisible: false,
backfillLoading: false,
backfillResult: null,
backfillForm: {
receiptType: 1,
eventType: '',
lastId: '',
limit: 500
}
}
},
computed: {
...mapGetters(['permissionsSysOriginPlatforms']),
startedLocked() {
if (!this.form.id) {
return false
}
// 以详情接口返回的 serverTime/startTime 为最终判定,避免运营电脑时间漂移绕过后端锁定。
return ['ACTIVE', 'ENDED'].indexOf(this.backendActivityStatus) >= 0 ||
(this.serverTime > 0 && this.originalStartTime > 0 && this.serverTime >= this.originalStartTime)
},
totalPrizeWeight() {
return this.form.prizes.reduce((total, item) => total + Number(item.weight || 0), 0)
}
},
created() {
this.loadActivities()
},
methods: {
emptyForm() {
return {
id: '',
activityCode: '',
activityName: '',
activityDesc: '',
sysOrigin: 'ATYOU',
timeZone: 'Asia/Riyadh',
timeRange: [],
coinPerDraw: 1,
settlementDelayMinutes: 30,
eventTypes: [],
rankingType: 'TYCOON',
rankingPeriod: 'OVERALL',
enabled: false,
prizes: this.emptyPrizes(),
rankRewards: this.emptyRankRewards()
}
},
emptyPrizes() {
return Array.from({ length: 7 }, (item, index) => ({
id: '',
activityId: '',
prizeName: '',
prizeImage: '',
weight: 1,
stock: -1,
resourceGroupId: '',
enabled: true,
sortOrder: index + 1,
rewards: []
}))
},
emptyRankRewards() {
return REQUIRED_RANK_RANGES.map((range, index) => ({
id: '',
activityId: '',
startRank: range[0],
endRank: range[1],
resourceGroupId: '',
rewardName: index < 3 ? `Top ${index + 1} 奖励` : `Top ${range[0]}-${range[1]} 奖励`,
rewards: []
}))
},
loadActivities() {
this.listLoading = true
listAslanGameKingActivities().then(res => {
this.activities = Array.isArray(res.body) ? res.body : []
}).finally(() => {
this.listLoading = false
})
},
openCreate() {
this.resetEditor()
const availablePlatforms = this.permissionsSysOriginPlatforms || []
if (availablePlatforms.length > 0) {
this.form.sysOrigin = availablePlatforms[0].value
}
this.editorVisible = true
},
openEdit(row) {
this.resetEditor()
this.editorVisible = true
this.editorLoading = true
getAslanGameKingDetail(row.id).then(res => {
const detail = res.body || {}
const activity = detail.activity || {}
const prizes = this.mergePrizes(detail.prizes || [])
const rankRewards = this.mergeRankRewards(detail.rankRewards || [])
this.form = Object.assign(this.emptyForm(), activity, {
timeZone: activity.timeZone || 'Asia/Riyadh',
settlementDelayMinutes: activity.settlementDelayMinutes === null || activity.settlementDelayMinutes === undefined
? 30 : Number(activity.settlementDelayMinutes),
timeRange: [activity.startTime, activity.endTime],
prizes,
rankRewards
})
this.serverTime = Number(detail.serverTime || 0)
this.originalStartTime = Number(activity.startTime || 0)
this.backendActivityStatus = detail.activityStatus || ''
}).finally(() => {
this.editorLoading = false
})
},
mergePrizes(prizes) {
const result = this.emptyPrizes()
prizes.forEach((item, index) => {
const targetIndex = Number(item.sortOrder || index + 1) - 1
if (targetIndex >= 0 && targetIndex < result.length) {
result.splice(targetIndex, 1, Object.assign({}, result[targetIndex], item, {
stock: item.stock === null || item.stock === undefined ? -1 : item.stock,
rewards: item.rewardItems || item.rewards || []
}))
}
})
return result
},
mergeRankRewards(rankRewards) {
const result = this.emptyRankRewards()
rankRewards.forEach(item => {
const index = REQUIRED_RANK_RANGES.findIndex(range => range[0] === Number(item.startRank) && range[1] === Number(item.endRank))
if (index >= 0) {
result.splice(index, 1, Object.assign({}, result[index], item, {
rewards: item.rewardItems || item.rewards || []
}))
}
})
return result
},
resetEditor() {
this.form = this.emptyForm()
this.serverTime = 0
this.originalStartTime = 0
this.backendActivityStatus = ''
this.resourceSelectorVisible = false
this.resourceSelection = null
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
// 基础配置、七个奖项与六档排行奖励一次提交,由后端事务保证不会保存出半套配置。
saveAslanGameKingActivity(payload).then(() => {
this.$message.success('活动配置已保存')
this.editorVisible = false
this.loadActivities()
}).finally(() => {
this.saveLoading = false
})
})
},
validateConfiguration() {
const range = this.form.timeRange || []
if (range.length !== 2 || Number(range[0]) >= Number(range[1])) {
this.$message.error('活动结束时间必须晚于开始时间')
return false
}
if (!Number.isSafeInteger(Number(this.form.coinPerDraw)) || Number(this.form.coinPerDraw) <= 0) {
this.$message.error('每次抽奖金币数必须是大于 0 的安全整数')
return false
}
if (!Number.isInteger(Number(this.form.settlementDelayMinutes)) ||
Number(this.form.settlementDelayMinutes) < 0 || Number(this.form.settlementDelayMinutes) > 1440) {
this.$message.error('结算等待必须是 0 到 1440 的整数分钟')
return false
}
const eventTypes = (this.form.eventTypes || []).map(item => String(item).trim())
const invalidEventType = eventTypes.find(item => !item || item.length > 64 || item.indexOf(',') >= 0)
if (invalidEventType !== undefined || new Set(eventTypes).size !== eventTypes.length) {
this.$message.error('eventType 必须非空、最长 64 字符、不含逗号且不可重复')
return false
}
// 活动开始后后端只允许更新非统计字段;奖项与排行奖励既不重新校验,也不随保存请求发送。
if (this.startedLocked) {
return true
}
if (this.form.prizes.length !== 7 || this.form.prizes.some(item => !item.enabled)) {
this.$message.error('必须且只能配置 7 个启用奖项')
return false
}
const invalidPrize = this.form.prizes.find(item =>
!item.prizeName || !item.prizeImage || !item.resourceGroupId ||
!Number.isSafeInteger(Number(item.weight)) || Number(item.weight) <= 0 ||
!Number.isSafeInteger(Number(item.stock)) || Number(item.stock) < -1
)
if (invalidPrize || this.totalPrizeWeight <= 0) {
this.$message.error('请补全 7 个奖项的名称、图片、正整数权重、合法库存和奖励资源组')
return false
}
const rangesValid = this.form.rankRewards.length === REQUIRED_RANK_RANGES.length &&
this.form.rankRewards.every((item, index) =>
Number(item.startRank) === REQUIRED_RANK_RANGES[index][0] &&
Number(item.endRank) === REQUIRED_RANK_RANGES[index][1] &&
Boolean(item.resourceGroupId)
)
if (!rangesValid) {
this.$message.error('六档名次范围必须保持固定,并为每档选择奖励资源组')
return false
}
return true
},
buildSavePayload() {
const payload = {
id: this.form.id || null,
activityCode: this.form.activityCode.trim(),
activityName: this.form.activityName.trim(),
activityDesc: this.form.activityDesc,
sysOrigin: this.form.sysOrigin,
timeZone: this.form.timeZone,
startTime: Number(this.form.timeRange[0]),
endTime: Number(this.form.timeRange[1]),
settlementDelayMinutes: Number(this.form.settlementDelayMinutes),
coinPerDraw: Number(this.form.coinPerDraw),
eventTypes: this.form.eventTypes.map(item => String(item).trim()),
rankingType: 'TYCOON',
rankingPeriod: 'OVERALL',
enabled: Boolean(this.form.enabled)
}
if (!this.startedLocked) {
payload.prizes = this.form.prizes.map(item => ({
id: item.id || null,
activityId: this.form.id || null,
prizeName: item.prizeName.trim(),
prizeImage: item.prizeImage,
weight: Number(item.weight),
stock: Number(item.stock),
resourceGroupId: String(item.resourceGroupId),
enabled: true,
sortOrder: Number(item.sortOrder)
}))
payload.rankRewards = this.form.rankRewards.map(item => ({
id: item.id || null,
activityId: this.form.id || null,
startRank: Number(item.startRank),
endRank: Number(item.endRank),
resourceGroupId: String(item.resourceGroupId),
rewardName: item.rewardName
}))
}
return payload
},
selectResourceGroup(type, index) {
if (this.startedLocked) {
return
}
if (!this.form.sysOrigin) {
this.$message.warning('请先选择所属平台')
return
}
this.resourceSelection = { type, index }
this.resourceSelectorVisible = true
},
applyResourceGroup(source) {
if (!this.resourceSelection) {
return
}
const list = this.resourceSelection.type === 'prize' ? this.form.prizes : this.form.rankRewards
const row = list[this.resourceSelection.index]
// Long ID 保留为字符串传回 Java避免 Snowflake ID 在浏览器里发生精度截断。
row.resourceGroupId = String(source.id)
row.rewards = source.rewardConfigList || []
if (this.resourceSelection.type === 'rank' && !row.rewardName) {
row.rewardName = source.name || ''
}
this.resourceSelectorVisible = false
this.resourceSelection = null
},
changeEnabled(row) {
const enabled = Boolean(row.enabled)
const action = enabled ? '启用' : '停用'
this.$confirm(`确认${action}活动「${row.activityName}」?`, '状态确认', { type: 'warning' }).then(() => {
return setAslanGameKingEnabled(row.id, enabled)
}).then(() => {
this.$message.success(`活动已${action}`)
this.loadActivities()
}).catch(() => {
// 确认取消和接口失败都恢复开关,列表状态只能以后端成功响应为准。
row.enabled = !enabled
})
},
openSettlements(row) {
this.selectedActivity = row
this.settlementsVisible = true
this.loadSettlements()
},
loadSettlements() {
if (!this.selectedActivity.id) {
return
}
this.settlementsLoading = true
listAslanGameKingSettlementRecords(this.selectedActivity.id).then(res => {
this.settlementRecords = Array.isArray(res.body) ? res.body : []
}).finally(() => {
this.settlementsLoading = false
})
},
manualSettle() {
this.$confirm('将按活动结束时最终消费榜生成 Top 30 奖励并立即尝试发放,确认继续?', '手工结算', {
type: 'warning'
}).then(() => {
this.settleLoading = true
return settleAslanGameKingActivity(this.selectedActivity.id)
}).then(() => {
this.$message.success('结算任务已执行')
this.loadSettlements()
this.loadActivities()
}).finally(() => {
this.settleLoading = false
})
},
retrySettlement(row) {
this.retryingRecordId = row.id
retryAslanGameKingSettlement(row.id).then(() => {
this.$message.success('该条奖励已重新发放')
this.loadSettlements()
}).finally(() => {
this.retryingRecordId = ''
})
},
openDrawRecords(row) {
this.selectedActivity = row
this.drawDeliveryStatus = ''
this.drawRecordsVisible = true
this.loadDrawRecords()
},
loadDrawRecords() {
if (!this.selectedActivity.id) {
return
}
const params = { limit: 200 }
if (this.drawDeliveryStatus) {
params.deliveryStatus = this.drawDeliveryStatus
}
this.drawRecordsLoading = true
listAslanGameKingDrawRecords(this.selectedActivity.id, params).then(res => {
this.drawRecords = Array.isArray(res.body) ? res.body : []
}).finally(() => {
this.drawRecordsLoading = false
})
},
retryDrawRecord(row) {
this.retryingDrawRecordId = row.id
retryAslanGameKingDraw(row.id).then(() => {
this.$message.success('该条抽奖奖励已重新发放')
this.loadDrawRecords()
}).finally(() => {
this.retryingDrawRecordId = ''
})
},
resolveUnknownDeliveryItem(item, delivered) {
const action = delivered ? '确认该奖励项已经到账并标记成功' : '确认该奖励项未到账并立即补发'
const warning = delivered
? '此操作不会再次发奖。请先按奖励项 ID 核对钱包流水或背包记录。'
: '此操作会真实补发奖励。只有下游核账明确未到账时才能继续,否则可能重复发放。'
this.$confirm(`${action}${warning}`, 'UNKNOWN 奖励核账', {
type: delivered ? 'warning' : 'error',
confirmButtonText: delivered ? '已核账,标记到账' : '已核账,确认补发'
}).then(() => {
this.resolvingDeliveryItemId = item.id
return resolveAslanGameKingUnknownDeliveryItem(item.id, delivered)
}).then(() => {
this.$message.success(delivered ? '已标记该奖励项到账' : '已解锁并执行补发')
if (this.drawRecordsVisible) {
this.loadDrawRecords()
}
if (this.settlementsVisible) {
this.loadSettlements()
}
}).finally(() => {
this.resolvingDeliveryItemId = ''
})
},
openBackfill(row) {
this.selectedActivity = row
this.backfillForm = {
receiptType: 1,
eventType: row.eventTypes && row.eventTypes.length ? row.eventTypes[0] : '',
lastId: '',
limit: 500
}
this.backfillResult = null
this.backfillVisible = true
},
runBackfill() {
if (this.backfillForm.receiptType !== 0 && this.backfillForm.receiptType !== 1) {
this.$message.error('请选择金币流水类型')
return
}
if (!this.backfillForm.eventType) {
this.$message.error('请选择一个精确 eventType')
return
}
const params = {
eventType: this.backfillForm.eventType,
receiptType: this.backfillForm.receiptType,
limit: Number(this.backfillForm.limit)
}
if (this.backfillForm.lastId) {
params.lastId = this.backfillForm.lastId
}
this.backfillLoading = true
backfillAslanGameKingConsumption(this.selectedActivity.id, params).then(res => {
this.backfillResult = res.body || {}
// 游标由服务端返回并按 eventType 独立推进;浏览器不自行计算,避免大整数精度和跳行问题。
this.backfillForm.lastId = this.backfillResult.nextLastId || this.backfillForm.lastId
this.$message.success(this.backfillResult.finished ? '该 eventType 已补数完成' : '本批补数完成,请继续下一批')
}).finally(() => {
this.backfillLoading = false
})
},
resetBackfillCursor() {
// lastId 仅在同一 receiptType + eventType 查询条件内有效,切换条件必须从首批重新开始。
this.backfillForm.lastId = ''
this.backfillResult = null
},
activityStatus(row) {
if (!row || !row.id) {
return 'DISABLED'
}
if (!row.enabled) {
return 'DISABLED'
}
const now = Date.now()
if (now < Number(row.startTime)) {
return 'UPCOMING'
}
if (now >= Number(row.endTime)) {
return 'ENDED'
}
return 'ACTIVE'
},
hasStarted(row) {
return Boolean(row && row.startTime && Date.now() >= Number(row.startTime))
},
settlementReady(row) {
// 手工结算只负责首次冻结榜单PROCESSING/PARTIAL_FAILED 的恢复与 UNKNOWN 核账
// 走各自专用链路,避免运营把“继续发放”误当成再次生成快照。
if (!row || !row.id || row.settlementStatus !== 'NOT_STARTED') {
return false
}
const settlementTime = Number(row.settlementTime ||
(Number(row.endTime) + Number(row.settlementDelayMinutes || 0) * 60 * 1000))
return Number.isFinite(settlementTime) && Date.now() >= settlementTime
},
activityStatusText(status) {
return {
DISABLED: '未启用',
UPCOMING: '待开始',
ACTIVE: '进行中',
ENDED: '已结束'
}[status] || status
},
activityStatusTag(status) {
return {
DISABLED: 'info',
UPCOMING: 'warning',
ACTIVE: 'success',
ENDED: 'danger'
}[status] || 'info'
},
settlementStatusText(status) {
return {
NOT_STARTED: '未结算',
PROCESSING: '结算中',
COMPLETED: '已完成',
PARTIAL_FAILED: '部分失败'
}[status] || status || '未结算'
},
deliveryStatusTag(status) {
return {
SUCCESS: 'success',
FAILED: 'danger',
UNKNOWN: 'warning',
PROCESSING: 'warning',
PENDING: 'info'
}[status] || 'info'
},
rankRangeText(row) {
return row.startRank === row.endRank ? `Top ${row.startRank}` : `Top ${row.startRank}-${row.endRank}`
},
formatTime(value) {
if (!value) {
return '-'
}
const date = new Date(Number(value))
if (Number.isNaN(date.getTime())) {
return '-'
}
const pad = number => String(number).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
formatNumber(value) {
if (value === null || value === undefined || value === '') {
return '-'
}
return Number(value).toLocaleString('en-US', { maximumFractionDigits: 2 })
}
}
}
</script>
<style scoped lang="scss">
.aslan-game-king {
.filter-container {
display: flex;
align-items: flex-start;
margin-bottom: 15px;
}
.page-tip {
flex: 1;
margin-left: 15px;
}
.primary-text {
color: #303133;
font-weight: 600;
}
.secondary-text,
.form-help {
color: #909399;
font-size: 12px;
line-height: 20px;
}
.event-tag {
margin: 2px 4px 2px 0;
}
.settlement-status {
margin-top: 6px;
color: #606266;
font-size: 12px;
}
.editor-body {
max-height: 76vh;
overflow-y: auto;
padding: 0 8px 20px;
}
.section-title {
margin: 22px 0 14px;
padding-left: 10px;
border-left: 4px solid #409eff;
color: #303133;
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.config-table {
margin-top: 12px;
}
.weight-summary {
padding: 10px 5px;
color: #606266;
text-align: right;
}
.readonly-prize-image {
width: 72px;
height: 72px;
}
.dialog-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.backfill-form {
margin-top: 20px;
}
.delivery-item-table {
margin: 8px 0;
}
.danger-link {
color: #f56c6c;
}
}
</style>