[色子游戏]奖励配置权重测试记录
This commit is contained in:
@@ -11,23 +11,42 @@ export default {
|
||||
*/
|
||||
list(params: Record<string, any>) {
|
||||
return request.get<Api.Common.ApiPage>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/index',
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/index',
|
||||
params
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 DiceLotteryPoolConfig 列表数据,仅含 id、name,用于 lottery_config_id 下拉
|
||||
* @returns DiceLotteryPoolConfig['id','name'] 列表
|
||||
* @returns DiceLotteryPoolConfig['id','name','t1_weight'..'t5_weight'] 列表
|
||||
*/
|
||||
async getOptions(): Promise<Array<{ id: number; name: string }>> {
|
||||
async getOptions(): Promise<
|
||||
Array<{
|
||||
id: number
|
||||
name: string
|
||||
t1_weight: number
|
||||
t2_weight: number
|
||||
t3_weight: number
|
||||
t4_weight: number
|
||||
t5_weight: number
|
||||
}>
|
||||
> {
|
||||
const res = await request.get<any>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions'
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions'
|
||||
})
|
||||
const rows = (res?.data ?? []) as Array<{ id: number; name: string }>
|
||||
return Array.isArray(rows)
|
||||
? rows.map((r) => ({ id: Number(r.id), name: String(r.name ?? r.id ?? '') }))
|
||||
: []
|
||||
// 兼容:request.get 通常返回后端 success(data) 的 data(数组);部分环境可能返回整包 { data: [] }
|
||||
const rows = Array.isArray(res) ? res : (Array.isArray((res as any)?.data) ? (res as any).data : [])
|
||||
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows.map((r: any) => ({
|
||||
id: Number(r.id),
|
||||
name: String(r.name ?? r.id ?? ''),
|
||||
t1_weight: Number(r.t1_weight ?? 0),
|
||||
t2_weight: Number(r.t2_weight ?? 0),
|
||||
t3_weight: Number(r.t3_weight ?? 0),
|
||||
t4_weight: Number(r.t4_weight ?? 0),
|
||||
t5_weight: Number(r.t5_weight ?? 0)
|
||||
}))
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -37,7 +56,7 @@ export default {
|
||||
*/
|
||||
read(id: number | string) {
|
||||
return request.get<Api.Common.ApiData>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/read?id=' + id
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/read?id=' + id
|
||||
})
|
||||
},
|
||||
|
||||
@@ -48,7 +67,7 @@ export default {
|
||||
*/
|
||||
save(params: Record<string, any>) {
|
||||
return request.post<any>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/save',
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/save',
|
||||
data: params
|
||||
})
|
||||
},
|
||||
@@ -60,7 +79,7 @@ export default {
|
||||
*/
|
||||
update(params: Record<string, any>) {
|
||||
return request.put<any>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/update',
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/update',
|
||||
data: params
|
||||
})
|
||||
},
|
||||
@@ -72,7 +91,7 @@ export default {
|
||||
*/
|
||||
delete(params: Record<string, any>) {
|
||||
return request.del<any>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/destroy',
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/destroy',
|
||||
data: params
|
||||
})
|
||||
},
|
||||
@@ -92,7 +111,7 @@ export default {
|
||||
t5_weight: number
|
||||
profit_amount: number
|
||||
}>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool'
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool'
|
||||
})
|
||||
},
|
||||
|
||||
@@ -108,7 +127,7 @@ export default {
|
||||
t5_weight?: number
|
||||
}) {
|
||||
return request.post<any>({
|
||||
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/updateCurrentPool',
|
||||
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/updateCurrentPool',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -80,5 +80,22 @@ export default {
|
||||
url: '/core/dice/reward_config/DiceRewardConfig/batchUpdateWeights',
|
||||
data: { items }
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 权重配比测试:按当前配置模拟 N 次抽奖,返回各 grid_number 落点次数
|
||||
* @param test_count 100 | 500 | 1000
|
||||
* @param save_record 是否保存到 dice_reward_config_record
|
||||
* @param lottery_config_id 奖池配置ID(DiceLotteryPoolConfig),用于设定 T1-T5 档位概率;不传则使用 type=0 或均等
|
||||
*/
|
||||
runWeightTest(params: {
|
||||
test_count: number
|
||||
save_record?: boolean
|
||||
lottery_config_id?: number | null
|
||||
}) {
|
||||
return request.post<{ data: { counts: Record<string, number>; record_id: number | null } }>({
|
||||
url: '/core/dice/reward_config/DiceRewardConfig/runWeightTest',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import request from '@/utils/http'
|
||||
|
||||
/**
|
||||
* 奖励配置权重测试记录 API接口
|
||||
*/
|
||||
export default {
|
||||
/**
|
||||
* 获取数据列表
|
||||
* @param params 搜索参数
|
||||
* @returns 数据列表
|
||||
*/
|
||||
list(params: Record<string, any>) {
|
||||
return request.get<Api.Common.ApiPage>({
|
||||
url: '/dice/reward_config_record/DiceRewardConfigRecord/index',
|
||||
params
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 读取数据
|
||||
* @param id 数据ID
|
||||
* @returns 数据详情
|
||||
*/
|
||||
read(id: number | string) {
|
||||
return request.get<Api.Common.ApiData>({
|
||||
url: '/dice/reward_config_record/DiceRewardConfigRecord/read?id=' + id
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建数据
|
||||
* @param params 数据参数
|
||||
* @returns 执行结果
|
||||
*/
|
||||
save(params: Record<string, any>) {
|
||||
return request.post<any>({
|
||||
url: '/dice/reward_config_record/DiceRewardConfigRecord/save',
|
||||
data: params
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新数据
|
||||
* @param params 数据参数
|
||||
* @returns 执行结果
|
||||
*/
|
||||
update(params: Record<string, any>) {
|
||||
return request.put<any>({
|
||||
url: '/dice/reward_config_record/DiceRewardConfigRecord/update',
|
||||
data: params
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除数据
|
||||
* @param id 数据ID
|
||||
* @returns 执行结果
|
||||
*/
|
||||
delete(params: Record<string, any>) {
|
||||
return request.del<any>({
|
||||
url: '/dice/reward_config_record/DiceRewardConfigRecord/destroy',
|
||||
data: params
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 导入:将测试记录的权重写入 DiceRewardConfig 与 DiceLotteryPoolConfig,并刷新缓存
|
||||
* @param record_id 测试记录 ID
|
||||
* @param lottery_config_id 可选,导入档位权重到的奖池配置 ID,不传则用记录内的 lottery_config_id
|
||||
*/
|
||||
importFromRecord(params: { record_id: number; lottery_config_id?: number | null }) {
|
||||
return request.post<any>({
|
||||
url: '/dice/reward_config_record/DiceRewardConfigRecord/importFromRecord',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@
|
||||
>
|
||||
T1-T5 与 BIGWIN 权重配比
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="'dice:reward_config:index:index'"
|
||||
type="success"
|
||||
@click="weightTestVisible = true"
|
||||
v-ripple
|
||||
>
|
||||
测试中奖
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
@@ -60,6 +68,8 @@
|
||||
/>
|
||||
<!-- T1-T5、BIGWIN 权重配比弹窗 -->
|
||||
<WeightRatioDialog v-model="weightRatioVisible" @success="refreshData" />
|
||||
<!-- 权重配比测试弹窗 -->
|
||||
<WeightTestDialog v-model="weightTestVisible" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -70,8 +80,10 @@
|
||||
import TableSearch from './modules/table-search.vue'
|
||||
import EditDialog from './modules/edit-dialog.vue'
|
||||
import WeightRatioDialog from './modules/weight-ratio-dialog.vue'
|
||||
import WeightTestDialog from './modules/weight-test-dialog.vue'
|
||||
|
||||
const weightRatioVisible = ref(false)
|
||||
const weightTestVisible = ref(false)
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = ref<Record<string, unknown>>({
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="权重配比测试"
|
||||
width="720px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="!result" class="test-form">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="奖池配置">
|
||||
<el-select
|
||||
v-model="form.lottery_config_id"
|
||||
placeholder="选择 T1-T5 档位概率来源,不选则使用默认"
|
||||
clearable
|
||||
style="width: 320px"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in lotteryConfigOptions"
|
||||
:key="opt.id"
|
||||
:label="opt.name"
|
||||
:value="opt.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">
|
||||
{{
|
||||
selectedTierSummary || '选定奖池的 t1_weight~t5_weight 将作为测试时 T1-T5 的抽取概率'
|
||||
}}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="测试次数">
|
||||
<el-radio-group v-model="form.test_count">
|
||||
<el-radio :value="100">100 次</el-radio>
|
||||
<el-radio :value="500">500 次</el-radio>
|
||||
<el-radio :value="1000">1000 次</el-radio>
|
||||
<el-radio :value="5000">5000 次</el-radio>
|
||||
<el-radio :value="10000">10000 次</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="保存记录">
|
||||
<el-switch v-model="form.save_record" />
|
||||
<span class="form-tip">保存到「测试记录表」便于后续对比</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div v-else class="test-result">
|
||||
<div class="result-summary">
|
||||
共模拟 <strong>{{ form.test_count }}</strong> 次抽奖,落点分布如下:
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<ArtBarChart
|
||||
x-axis-name="色子点数 (grid_number)"
|
||||
:x-axis-data="chartLabels"
|
||||
:data="chartData"
|
||||
height="320px"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="result.record_id" class="record-tip"
|
||||
>已保存至测试记录,记录 ID:{{ result.record_id }}</div
|
||||
>
|
||||
</div>
|
||||
<template #footer>
|
||||
<template v-if="!result">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleRun">开始测试</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" @click="handleReset">再测一次</el-button>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import api from '../../../api/reward_config/index'
|
||||
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
|
||||
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const GRID_NUMBERS = [
|
||||
5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
|
||||
30
|
||||
]
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
lottery_config_id: null as number | null,
|
||||
test_count: 100 as 100 | 500 | 1000 | 5000 | 10000,
|
||||
save_record: true
|
||||
})
|
||||
|
||||
const lotteryConfigOptions = ref<
|
||||
Array<{
|
||||
id: number
|
||||
name: string
|
||||
t1_weight: number
|
||||
t2_weight: number
|
||||
t3_weight: number
|
||||
t4_weight: number
|
||||
t5_weight: number
|
||||
}>
|
||||
>([])
|
||||
const submitting = ref(false)
|
||||
const result = ref<{ counts: Record<string, number>; record_id: number | null } | null>(null)
|
||||
|
||||
const selectedLotteryConfig = computed(
|
||||
() => lotteryConfigOptions.value.find((opt) => opt.id === form.value.lottery_config_id) || null
|
||||
)
|
||||
|
||||
const selectedTierSummary = computed(() => {
|
||||
const opt = selectedLotteryConfig.value
|
||||
if (!opt) return ''
|
||||
const t1 = opt.t1_weight
|
||||
const t2 = opt.t2_weight
|
||||
const t3 = opt.t3_weight
|
||||
const t4 = opt.t4_weight
|
||||
const t5 = opt.t5_weight
|
||||
const total = t1 + t2 + t3 + t4 + t5
|
||||
if (!total) {
|
||||
return `当前奖池 T1-T5 权重:T1=${t1} T2=${t2} T3=${t3} T4=${t4} T5=${t5}(总和为 0,测试时会按均等档位概率)`
|
||||
}
|
||||
const p = (v: number) => ((v / total) * 100).toFixed(1)
|
||||
return `当前奖池 T1-T5 权重:T1=${t1} (${p(t1)}%),T2=${t2} (${p(t2)}%),T3=${t3} (${p(t3)}%),T4=${t4} (${p(t4)}%),T5=${t5} (${p(t5)}%)`
|
||||
})
|
||||
|
||||
const chartLabels = computed(() => GRID_NUMBERS.map((n) => String(n)))
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!result.value?.counts) return GRID_NUMBERS.map(() => 0)
|
||||
const counts = result.value.counts
|
||||
return GRID_NUMBERS.map((n) => {
|
||||
const v = counts[String(n)] ?? counts[n]
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : 0
|
||||
})
|
||||
})
|
||||
|
||||
async function loadLotteryOptions() {
|
||||
try {
|
||||
const list = await lotteryConfigApi.getOptions()
|
||||
lotteryConfigOptions.value = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
lotteryConfigOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
submitting.value = true
|
||||
result.value = null
|
||||
try {
|
||||
const res = await api.runWeightTest({
|
||||
test_count: form.value.test_count,
|
||||
save_record: form.value.save_record,
|
||||
lottery_config_id: form.value.lottery_config_id ?? undefined
|
||||
})
|
||||
const data = (res as any)?.data ?? res
|
||||
result.value = {
|
||||
counts: data.counts ?? {},
|
||||
record_id: data.record_id ?? null
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? '测试请求失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
result.value = null
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
result.value = null
|
||||
}
|
||||
|
||||
watch(visible, (open) => {
|
||||
if (open) {
|
||||
loadLotteryOptions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.test-form {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.form-tip {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.test-result {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.result-summary {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.chart-wrap {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.record-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="art-full-height">
|
||||
<!-- 搜索面板 -->
|
||||
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
|
||||
|
||||
<ElCard class="art-table-card" shadow="never">
|
||||
<!-- 表格头部 -->
|
||||
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
|
||||
<template #left>
|
||||
<ElSpace wrap>
|
||||
<ElButton
|
||||
v-permission="'dice:reward_config_record:index:save'"
|
||||
@click="showDialog('add')"
|
||||
v-ripple
|
||||
>
|
||||
<template #icon>
|
||||
<ArtSvgIcon icon="ri:add-fill" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-permission="'dice:reward_config_record:index:destroy'"
|
||||
:disabled="selectedRows.length === 0"
|
||||
@click="deleteSelectedRows(api.delete, refreshData)"
|
||||
v-ripple
|
||||
>
|
||||
<template #icon>
|
||||
<ArtSvgIcon icon="ri:delete-bin-5-line" />
|
||||
</template>
|
||||
删除
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</template>
|
||||
</ArtTableHeader>
|
||||
|
||||
<!-- 表格 -->
|
||||
<ArtTable
|
||||
ref="tableRef"
|
||||
rowKey="id"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@sort-change="handleSortChange"
|
||||
@selection-change="handleSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
>
|
||||
<!-- 权重快照:显示条数 -->
|
||||
<template #snapshot_summary="{ row }">
|
||||
<span>{{ getSnapshotSummary(row) }}</span>
|
||||
</template>
|
||||
<!-- 落点统计:显示总次数 -->
|
||||
<template #result_summary="{ row }">
|
||||
<span>{{ getResultSummary(row) }}</span>
|
||||
</template>
|
||||
<!-- 操作列 -->
|
||||
<template #operation="{ row }">
|
||||
<div class="flex gap-2">
|
||||
<SaButton
|
||||
v-permission="'dice:reward_config_record:index:read'"
|
||||
type="success"
|
||||
toolTip="查看详情"
|
||||
@click="openDetail(row)"
|
||||
/>
|
||||
<SaButton
|
||||
v-permission="'dice:reward_config_record:index:destroy'"
|
||||
type="error"
|
||||
@click="deleteRow(row, api.delete, refreshData)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ArtTable>
|
||||
</ElCard>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<EditDialog
|
||||
v-model="dialogVisible"
|
||||
:dialog-type="dialogType"
|
||||
:data="dialogData"
|
||||
@success="refreshData"
|
||||
/>
|
||||
<!-- 详情抽屉 -->
|
||||
<DetailDrawer v-model="detailVisible" :record="detailRecord" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTable } from '@/hooks/core/useTable'
|
||||
import { useSaiAdmin } from '@/composables/useSaiAdmin'
|
||||
import api from '../../api/reward_config_record/index'
|
||||
import TableSearch from './modules/table-search.vue'
|
||||
import EditDialog from './modules/edit-dialog.vue'
|
||||
import DetailDrawer from './modules/detail-drawer.vue'
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = ref({})
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = (params: Record<string, any>) => {
|
||||
Object.assign(searchParams, params)
|
||||
getData()
|
||||
}
|
||||
|
||||
// 详情抽屉
|
||||
const detailVisible = ref(false)
|
||||
const detailRecord = ref<Record<string, any> | null>(null)
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailRecord.value = { ...row }
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 权重快照摘要:条数(兼容后端返回数组或字符串)
|
||||
function getSnapshotSummary(row: Record<string, any>): string {
|
||||
const snap = row.weight_config_snapshot
|
||||
if (Array.isArray(snap)) return `${snap.length} 条`
|
||||
if (snap && typeof snap === 'object') return `${Object.keys(snap).length} 条`
|
||||
if (typeof snap === 'string') {
|
||||
try {
|
||||
const arr = JSON.parse(snap)
|
||||
return Array.isArray(arr) ? `${arr.length} 条` : '—'
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
// 落点统计摘要:总次数
|
||||
function getResultSummary(row: Record<string, any>): string {
|
||||
const counts = row.result_counts
|
||||
if (counts && typeof counts === 'object' && !Array.isArray(counts)) {
|
||||
const total = Object.values(counts).reduce((s: number, v: any) => s + (Number(v) || 0), 0)
|
||||
return `${total} 次`
|
||||
}
|
||||
if (typeof counts === 'string') {
|
||||
try {
|
||||
const obj = JSON.parse(counts)
|
||||
if (obj && typeof obj === 'object') {
|
||||
const total = Object.values(obj).reduce((s: number, v: any) => s + (Number(v) || 0), 0)
|
||||
return `${total} 次`
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return '—'
|
||||
}
|
||||
|
||||
// 表格配置
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
getData,
|
||||
searchParams,
|
||||
pagination,
|
||||
resetSearchParams,
|
||||
handleSortChange,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: api.list,
|
||||
apiParams: { limit: 100 },
|
||||
columnsFactory: () => [
|
||||
{ type: 'selection' },
|
||||
{ prop: 'id', label: 'ID', width: 80, align: 'center' },
|
||||
{ prop: 'test_count', label: '测试次数', width: 100, align: 'center' },
|
||||
{
|
||||
prop: 'admin_name',
|
||||
label: '管理员',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
showOverflowTooltip: true
|
||||
},
|
||||
{
|
||||
prop: 'snapshot_summary',
|
||||
label: '权重快照',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
useSlot: true
|
||||
},
|
||||
{
|
||||
prop: 'result_summary',
|
||||
label: '落点统计',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
useSlot: true
|
||||
},
|
||||
{ prop: 'create_time', label: '创建时间', width: 170, align: 'center' },
|
||||
{
|
||||
prop: 'operation',
|
||||
label: '操作',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
useSlot: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑配置
|
||||
const {
|
||||
dialogType,
|
||||
dialogVisible,
|
||||
dialogData,
|
||||
showDialog,
|
||||
deleteRow,
|
||||
deleteSelectedRows,
|
||||
handleSelectionChange,
|
||||
selectedRows
|
||||
} = useSaiAdmin()
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
title="测试记录详情"
|
||||
:size="drawerSize"
|
||||
direction="rtl"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<template v-if="record">
|
||||
<div class="detail-section">
|
||||
<div class="section-title">基本信息</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="记录ID">
|
||||
{{ record.id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="测试次数">{{ record.test_count }} 次</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ record.create_time || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行管理员">
|
||||
{{ record.admin_name ?? record.admin_id ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="奖池配置ID" :span="2">
|
||||
{{ record.lottery_config_id ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">T1-T5 档位权重配比(测试时使用的奖池配比)</div>
|
||||
<el-table
|
||||
v-if="tierWeightsTableData.length"
|
||||
:data="tierWeightsTableData"
|
||||
border
|
||||
size="small"
|
||||
class="tier-weights-table"
|
||||
max-height="200"
|
||||
>
|
||||
<el-table-column prop="tier" label="档位" width="80" align="center" />
|
||||
<el-table-column prop="weight" label="权重" width="100" align="center" />
|
||||
<el-table-column prop="percent" label="占比" width="100" align="center" />
|
||||
</el-table>
|
||||
<div v-else class="empty-tip">
|
||||
暂无档位权重数据(旧记录可能未保存 tier_weights_snapshot)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">权重配比快照(测试时使用的 T1-T5/BIGWIN 配置)</div>
|
||||
<el-table
|
||||
:data="snapshotTableData"
|
||||
border
|
||||
size="small"
|
||||
max-height="280"
|
||||
class="snapshot-table"
|
||||
>
|
||||
<el-table-column prop="tier" label="档位" width="80" align="center" />
|
||||
<el-table-column prop="grid_number" label="色子点数" width="100" align="center" />
|
||||
<el-table-column prop="weight" label="权重" width="90" align="center" />
|
||||
<el-table-column prop="id" label="配置ID" width="80" align="center" />
|
||||
</el-table>
|
||||
<div v-if="!snapshotTableData.length" class="empty-tip">暂无快照数据</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-title">落点统计(各 grid_number 出现次数)</div>
|
||||
<div class="chart-wrap">
|
||||
<ArtBarChart
|
||||
x-axis-name="色子点数 (grid_number)"
|
||||
:x-axis-data="chartLabels"
|
||||
:data="chartData"
|
||||
height="280px"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="resultTotal === 0" class="empty-tip">暂无落点数据</div>
|
||||
<div v-else class="result-summary">总落点次数:{{ resultTotal }}</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section footer-actions">
|
||||
<el-button type="primary" :loading="importing" @click="openImport">
|
||||
导入到当前配置
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 导入弹窗 -->
|
||||
<el-dialog
|
||||
v-model="importVisible"
|
||||
title="导入权重到正式配置"
|
||||
width="460px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<p class="import-desc">
|
||||
将本测试记录的「权重配比快照」写入 DiceRewardConfig,将「T1-T5
|
||||
档位权重」写入所选奖池配置,并刷新缓存。
|
||||
</p>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="导入档位到奖池">
|
||||
<el-select
|
||||
v-model="importLotteryConfigId"
|
||||
placeholder="选择要写入 T1-T5 权重的奖池配置"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in lotteryConfigOptions"
|
||||
:key="opt.id"
|
||||
:label="opt.name"
|
||||
:value="opt.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">不选则使用本记录保存时的奖池配置 ID</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importing" @click="confirmImport">确认导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
|
||||
import recordApi from '../../../api/reward_config_record/index'
|
||||
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const GRID_NUMBERS = [
|
||||
5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
|
||||
30
|
||||
]
|
||||
|
||||
interface RecordRow {
|
||||
id?: number
|
||||
test_count?: number
|
||||
create_time?: string
|
||||
admin_id?: number | null
|
||||
admin_name?: string
|
||||
lottery_config_id?: number | null
|
||||
tier_weights_snapshot?: Record<string, number>
|
||||
weight_config_snapshot?: Array<{
|
||||
id?: number
|
||||
grid_number?: number
|
||||
tier?: string
|
||||
weight?: number
|
||||
}>
|
||||
result_counts?: Record<string, number>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
record: RecordRow | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'import-done'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
record: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const drawerSize = ref('720px')
|
||||
|
||||
const importVisible = ref(false)
|
||||
const importing = ref(false)
|
||||
const importLotteryConfigId = ref<number | null>(null)
|
||||
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
|
||||
const tierWeightsTableData = computed(() => {
|
||||
const t = props.record?.tier_weights_snapshot
|
||||
if (!t || typeof t !== 'object') return []
|
||||
const tiers = ['T1', 'T2', 'T3', 'T4', 'T5']
|
||||
const rows = tiers.map((tier) => {
|
||||
const w = t[tier] ?? t[tier.toLowerCase()] ?? 0
|
||||
return { tier, weight: w }
|
||||
})
|
||||
const total = rows.reduce((sum, r) => sum + r.weight, 0)
|
||||
return rows.map((r) => ({
|
||||
tier: r.tier,
|
||||
weight: r.weight,
|
||||
percent: total > 0 ? `${((r.weight / total) * 100).toFixed(1)}%` : '—'
|
||||
}))
|
||||
})
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const snapshotTableData = computed(() => {
|
||||
const snapshot = props.record?.weight_config_snapshot
|
||||
if (!Array.isArray(snapshot)) return []
|
||||
return snapshot.map((item) => ({
|
||||
id: item.id ?? '—',
|
||||
grid_number: item.grid_number ?? '—',
|
||||
tier: item.tier ?? '—',
|
||||
weight: item.weight ?? '—'
|
||||
}))
|
||||
})
|
||||
|
||||
const chartLabels = computed(() => GRID_NUMBERS.map((n) => String(n)))
|
||||
|
||||
const chartData = computed(() => {
|
||||
const counts = props.record?.result_counts
|
||||
if (!counts || typeof counts !== 'object') return GRID_NUMBERS.map(() => 0)
|
||||
return GRID_NUMBERS.map((n) => {
|
||||
const v = counts[String(n)] ?? counts[n]
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : 0
|
||||
})
|
||||
})
|
||||
|
||||
const resultTotal = computed(() => {
|
||||
const data = chartData.value
|
||||
return data.reduce((sum, n) => sum + n, 0)
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
visible.value = false
|
||||
importVisible.value = false
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const updateDrawerSize = () => {
|
||||
drawerSize.value = window.innerWidth <= 768 ? '100%' : '720px'
|
||||
}
|
||||
updateDrawerSize()
|
||||
window.addEventListener('resize', updateDrawerSize)
|
||||
}
|
||||
|
||||
async function loadLotteryOptions() {
|
||||
try {
|
||||
const list = await lotteryConfigApi.getOptions()
|
||||
lotteryConfigOptions.value = Array.isArray(list) ? list : []
|
||||
} catch {
|
||||
lotteryConfigOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
importLotteryConfigId.value = props.record?.lottery_config_id ?? null
|
||||
importVisible.value = true
|
||||
loadLotteryOptions()
|
||||
}
|
||||
|
||||
async function confirmImport() {
|
||||
const id = props.record?.id
|
||||
if (!id) return
|
||||
importing.value = true
|
||||
try {
|
||||
await recordApi.importFromRecord({
|
||||
record_id: id,
|
||||
lottery_config_id: importLotteryConfigId.value ?? undefined
|
||||
})
|
||||
ElMessage.success('导入成功,已刷新奖励配置与奖池配置')
|
||||
importVisible.value = false
|
||||
emit('import-done')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message ?? '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-drawer__body) {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.snapshot-table {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chart-wrap {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.result-summary {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.tier-weights-table {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.footer-actions {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.import-desc {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="dialogType === 'add' ? '新增奖励配置权重测试记录' : '编辑奖励配置权重测试记录'"
|
||||
width="600px"
|
||||
align-center
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
|
||||
<el-form-item label="测试次数:100/500/1000" prop="test_count">
|
||||
<el-input v-model="formData.test_count" placeholder="请输入测试次数:100/500/1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试时权重配比快照:按档位保存 id,grid_number,tier,weight" prop="weight_config_snapshot">
|
||||
<el-input v-model="formData.weight_config_snapshot" placeholder="请输入测试时权重配比快照:按档位保存 id,grid_number,tier,weight" />
|
||||
</el-form-item>
|
||||
<el-form-item label="落点统计:grid_number=>出现次数" prop="result_counts">
|
||||
<el-input v-model="formData.result_counts" placeholder="请输入落点统计:grid_number=>出现次数" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import api from '../../../api/reward_config_record/index'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
dialogType: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
dialogType: 'add',
|
||||
data: undefined
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
/**
|
||||
* 弹窗显示状态双向绑定
|
||||
*/
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 表单验证规则
|
||||
*/
|
||||
const rules = reactive<FormRules>({
|
||||
test_count: [{ required: true, message: '测试次数:100/500/1000必需填写', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
/**
|
||||
* 初始数据
|
||||
*/
|
||||
const initialFormData = {
|
||||
id: null,
|
||||
test_count: 100,
|
||||
weight_config_snapshot: '',
|
||||
result_counts: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据
|
||||
*/
|
||||
const formData = reactive({ ...initialFormData })
|
||||
|
||||
/**
|
||||
* 监听弹窗打开,初始化表单数据
|
||||
*/
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
initPage()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 初始化页面数据
|
||||
*/
|
||||
const initPage = async () => {
|
||||
// 先重置为初始值
|
||||
Object.assign(formData, initialFormData)
|
||||
// 如果有数据,则填充数据
|
||||
if (props.data) {
|
||||
await nextTick()
|
||||
initForm()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化表单数据
|
||||
*/
|
||||
const initForm = () => {
|
||||
if (props.data) {
|
||||
for (const key in formData) {
|
||||
if (props.data[key] != null && props.data[key] != undefined) {
|
||||
;(formData as any)[key] = props.data[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗并重置表单
|
||||
*/
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交表单
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
if (props.dialogType === 'add') {
|
||||
await api.save(formData)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
await api.update(formData)
|
||||
ElMessage.success('修改成功')
|
||||
}
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.log('表单验证失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<sa-search-bar
|
||||
ref="searchBarRef"
|
||||
v-model="formData"
|
||||
label-width="100px"
|
||||
:showExpand="false"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
@expand="handleExpand"
|
||||
>
|
||||
</sa-search-bar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue: Record<string, any>
|
||||
}
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: Record<string, any>): void
|
||||
(e: 'search', params: Record<string, any>): void
|
||||
(e: 'reset'): void
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
// 展开/收起
|
||||
const isExpanded = ref<boolean>(false)
|
||||
|
||||
// 表单数据双向绑定
|
||||
const searchBarRef = ref()
|
||||
const formData = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 重置
|
||||
function handleReset() {
|
||||
searchBarRef.value?.ref.resetFields()
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
// 搜索
|
||||
async function handleSearch() {
|
||||
emit('search', formData.value)
|
||||
}
|
||||
|
||||
// 展开/收起
|
||||
function handleExpand(expanded: boolean) {
|
||||
isExpanded.value = expanded
|
||||
}
|
||||
|
||||
// 栅格占据的列数
|
||||
const setSpan = (span: number) => {
|
||||
return {
|
||||
span: span,
|
||||
xs: 24, // 手机:满宽显示
|
||||
sm: span >= 12 ? span : 12, // 平板:大于等于12保持,否则用半宽
|
||||
md: span >= 8 ? span : 8, // 中等屏幕:大于等于8保持,否则用三分之一宽
|
||||
lg: span,
|
||||
xl: span
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user