优化中奖权重计算方式

This commit is contained in:
2026-03-12 17:17:00 +08:00
parent 064ce06393
commit 7e4ba86afa
25 changed files with 2344 additions and 403 deletions

View File

@@ -0,0 +1,127 @@
<template>
<div class="art-full-height">
<!-- 方向切换 + 搜索 -->
<div class="direction-bar">
<el-radio-group v-model="currentDirection" size="default" @change="onDirectionChange">
<el-radio-button :value="0">顺时针</el-radio-button>
<el-radio-button :value="1">逆时针</el-radio-button>
</el-radio-group>
</div>
<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:index:update'"
type="primary"
@click="weightRatioVisible = true"
v-ripple
>
权重配比
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
<WeightRatioDialog v-model="weightRatioVisible" @success="refreshData" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import api from '../../api/reward/index'
import TableSearch from './modules/table-search.vue'
import WeightRatioDialog from './modules/weight-ratio-dialog.vue'
const currentDirection = ref<0 | 1>(0)
const weightRatioVisible = ref(false)
const searchForm = ref<Record<string, unknown>>({
direction: 0,
tier: undefined
})
const listApi = (params: Record<string, any>) => {
return api.list({ ...params, direction: currentDirection.value })
}
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, { ...params, direction: currentDirection.value })
getData()
}
const onDirectionChange = () => {
searchForm.value.direction = currentDirection.value
Object.assign(searchParams, { direction: currentDirection.value, tier: searchForm.value.tier })
getData()
}
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: listApi,
apiParams: { direction: 0, limit: 100 },
columnsFactory: () => [
{ prop: 'start_index', label: '起始索引', width: 100, align: 'center' },
{ prop: 'end_index', label: '结束索引(end_index)', width: 110, align: 'center' },
{ prop: 'tier', label: '档位', width: 90, align: 'center', sortable: true },
{
prop: 'grid_number',
label: '色子点数(摇取5-30)',
width: 120,
align: 'center',
sortable: true,
showOverflowTooltip: true
},
{
prop: 'ui_text',
label: '显示文本',
minWidth: 100,
align: 'center',
showOverflowTooltip: true
},
{ prop: 'real_ev', label: '实际中奖金额', width: 110, align: 'center' },
{ prop: 'remark', label: '备注', minWidth: 80, align: 'center', showOverflowTooltip: true },
{ prop: 'weight', label: '权重(1-10000)', width: 110, align: 'center' }
]
}
})
onMounted(() => {
searchParams.direction = currentDirection.value
})
</script>
<style lang="scss" scoped>
.direction-bar {
margin-bottom: 12px;
padding: 8px 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
>
<el-col v-bind="setSpan(8)">
<el-form-item label="档位" prop="tier">
<el-select v-model="formData.tier" placeholder="全部" clearable style="width: 100%">
<el-option label="T1" value="T1" />
<el-option label="T2" value="T2" />
<el-option label="T3" value="T3" />
<el-option label="T4" value="T4" />
<el-option label="T5" value="T5" />
<el-option label="BIGWIN" value="BIGWIN" />
</el-select>
</el-form-item>
</el-col>
</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 searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
function handleReset() {
searchBarRef.value?.ref?.resetFields?.()
emit('reset')
}
function handleSearch() {
emit('search', { ...formData.value })
}
const setSpan = (span: number) => ({
span,
xs: 24,
sm: 12,
md: 8,
lg: span,
xl: span
})
</script>

View File

@@ -0,0 +1,477 @@
<template>
<el-dialog
v-model="visible"
title="奖励对照表dice_reward权重配比"
width="900px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<div class="global-tip">
编辑的是<strong>奖励对照表dice_reward / DiceReward 模型</strong>的权重<strong>结束索引end_index</strong>区分
<strong>顺时针</strong><strong>逆时针</strong>两套权重抽奖时按当前方向取对应权重
</div>
<div v-loading="loading" class="dialog-body">
<el-tabs v-model="activeTier" type="card">
<el-tab-pane v-for="t in tierKeys" :key="t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">该档位暂无配置数据</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<div class="chart-row">
<ArtBarChart
x-axis-name="结束索引"
:x-axis-data="getTierChartLabels(t)"
:data="getTierChartData(t, 'clockwise')"
height="180px"
/>
<ArtBarChart
x-axis-name="结束索引"
:x-axis-data="getTierChartLabels(t)"
:data="getTierChartData(t, 'counterclockwise')"
height="180px"
/>
</div>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
当前档位权重合计顺时针<strong>{{ getTierSum(t, 'clockwise') }}</strong>
逆时针<strong>{{ getTierSum(t, 'counterclockwise') }}</strong>
各条 1-10000和不限制
</div>
<div class="weight-sum weight-sum-t4t5" v-else>T4T5 仅单一结果无需配置权重</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column label="结束索引(id)" prop="id" width="90" align="center" show-overflow-tooltip />
<el-table-column label="色子点数" prop="grid_number" width="80" align="center" />
<el-table-column label="实际中奖金额" prop="real_ev" width="90" align="center" show-overflow-tooltip />
<el-table-column label="显示文本" prop="ui_text" min-width="70" align="center" show-overflow-tooltip />
<el-table-column label="备注" prop="remark" min-width="70" align="center" show-overflow-tooltip />
<el-table-column label="顺时针权重(direction=0)" min-width="160" align="center">
<template #default="{ row }">
<div class="weight-cell-vertical">
<div class="weight-slider-wrap">
<el-slider
:model-value="getItemWeight(row, 'clockwise')"
:min="1"
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
setItemWeightByRow(t, row, 'clockwise', Array.isArray(v) ? (v[0] ?? 1) : (v ?? 1))
"
/>
</div>
<div class="weight-input-wrap">
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeight(row, 'clockwise') <= 1"
@click="setItemWeightByRow(t, row, 'clockwise', Math.max(1, getItemWeight(row, 'clockwise') - 1))"
></el-button>
<el-input-number
:model-value="getItemWeight(row, 'clockwise')"
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@update:model-value="
(v: number | string | undefined) =>
setItemWeightByRow(
t,
row,
'clockwise',
typeof v === 'number' && !Number.isNaN(v) ? v : Number(v) || 1
)
"
/>
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeight(row, 'clockwise') >= 10000"
@click="setItemWeightByRow(t, row, 'clockwise', Math.min(10000, getItemWeight(row, 'clockwise') + 1))"
></el-button>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="逆时针权重(direction=1)" min-width="160" align="center">
<template #default="{ row }">
<div class="weight-cell-vertical">
<div class="weight-slider-wrap">
<el-slider
:model-value="getItemWeight(row, 'counterclockwise')"
:min="1"
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="
(v: number | number[]) =>
setItemWeightByRow(t, row, 'counterclockwise', Array.isArray(v) ? (v[0] ?? 1) : (v ?? 1))
"
/>
</div>
<div class="weight-input-wrap">
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeight(row, 'counterclockwise') <= 1"
@click="setItemWeightByRow(t, row, 'counterclockwise', Math.max(1, getItemWeight(row, 'counterclockwise') - 1))"
></el-button>
<el-input-number
:model-value="getItemWeight(row, 'counterclockwise')"
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@update:model-value="
(v: number | string | undefined) =>
setItemWeightByRow(
t,
row,
'counterclockwise',
typeof v === 'number' && !Number.isNaN(v) ? v : Number(v) || 1
)
"
/>
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeight(row, 'counterclockwise') >= 10000"
@click="setItemWeightByRow(t, row, 'counterclockwise', Math.min(10000, getItemWeight(row, 'counterclockwise') + 1))"
></el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</template>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/reward/index'
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus'
const TIER_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
type DirectionKey = 'clockwise' | 'counterclockwise'
interface WeightRow {
reward_id_clockwise?: number
reward_id_counterclockwise?: number
id?: number
grid_number?: number
real_ev?: number
ui_text?: string
remark?: string
tier?: string
weight_clockwise: number
weight_counterclockwise: number
}
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): 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 activeTier = ref('T1')
const submitting = ref(false)
const loading = ref(false)
const grouped = ref<Record<string, WeightRow[]>>({
T1: [],
T2: [],
T3: [],
T4: [],
T5: []
})
function getTierItems(tier: string): WeightRow[] {
return grouped.value[tier] ?? []
}
function getTierChartLabels(tier: string): string[] {
return getTierItems(tier).map((r) => String(r.grid_number ?? ''))
}
function getTierChartData(tier: string, dir: DirectionKey): number[] {
const key = dir === 'clockwise' ? 'weight_clockwise' : 'weight_counterclockwise'
return getTierItems(tier).map((r) => toWeightPrecision(r[key] ?? 1))
}
function getTierSum(tier: string, dir: DirectionKey): number {
const key = dir === 'clockwise' ? 'weight_clockwise' : 'weight_counterclockwise'
return getTierItems(tier).reduce((s, r) => s + toWeightPrecision(r[key] ?? 1), 0)
}
function getItemWeight(row: WeightRow, dir: DirectionKey): number {
const key = dir === 'clockwise' ? 'weight_clockwise' : 'weight_counterclockwise'
const w = row[key]
const num = typeof w === 'number' && !Number.isNaN(w) ? w : Number(w)
if (Number.isNaN(num)) return 1
return toWeightPrecision(num)
}
function toWeightPrecision(value: number): number {
return Math.max(1, Math.min(10000, Math.floor(value)))
}
function setItemWeightByRow(tier: string, row: WeightRow, dir: DirectionKey, value: number) {
const v = toWeightPrecision(typeof value === 'number' && !Number.isNaN(value) ? value : 1)
const list = grouped.value[tier]
if (!list) return
const key = dir === 'clockwise' ? 'weight_clockwise' : 'weight_counterclockwise'
const rid =
dir === 'clockwise' ? row.reward_id_clockwise : row.reward_id_counterclockwise
const idx = list.findIndex(
(r) =>
r === row ||
(rid != null &&
(dir === 'clockwise' ? r.reward_id_clockwise : r.reward_id_counterclockwise) === rid)
)
if (idx >= 0) list[idx][key] = v
else row[key] = v
}
function isWeightDisabled(row: WeightRow, tier: string): boolean {
if (tier === 'T4' || tier === 'T5') return true
return false
}
function normalizeWeightValue(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
return Math.max(1, Math.min(10000, Math.floor(num)))
}
/** 兼容接口返回 tier -> { 0: [], 1: [] },按 grid_number 合并为每档位一行,含 reward_id 与双方向权重 */
function parsePayload(res: any): Record<string, WeightRow[]> {
const empty: Record<string, WeightRow[]> = {
T1: [],
T2: [],
T3: [],
T4: [],
T5: []
}
if (!res || typeof res !== 'object') return empty
const raw = res?.data ?? res
if (!raw || typeof raw !== 'object') return empty
const out = { ...empty }
for (const t of TIER_KEYS) {
const tierData = raw[t]
if (!tierData || typeof tierData !== 'object') continue
const list0 = Array.isArray(tierData[0]) ? tierData[0] : []
const list1 = Array.isArray(tierData[1]) ? tierData[1] : []
const byGrid = new Map<
number,
{
reward_id_clockwise?: number
reward_id_counterclockwise?: number
id?: number
grid_number?: number
real_ev?: number
ui_text?: string
remark?: string
weight_clockwise: number
weight_counterclockwise: number
}
>()
for (const r of list0) {
const gn = r.grid_number != null ? Number(r.grid_number) : NaN
if (!Number.isNaN(gn)) {
byGrid.set(gn, {
reward_id_clockwise: r.reward_id != null ? Number(r.reward_id) : undefined,
id: r.id != null ? Number(r.id) : undefined,
grid_number: gn,
real_ev: r.real_ev,
ui_text: r.ui_text,
remark: r.remark,
weight_clockwise: normalizeWeightValue(r.weight),
weight_counterclockwise: 1
})
}
}
for (const r of list1) {
const gn = r.grid_number != null ? Number(r.grid_number) : NaN
if (!Number.isNaN(gn)) {
const cur = byGrid.get(gn)
if (cur) {
cur.reward_id_counterclockwise = r.reward_id != null ? Number(r.reward_id) : undefined
cur.weight_counterclockwise = normalizeWeightValue(r.weight)
} else {
byGrid.set(gn, {
reward_id_counterclockwise: r.reward_id != null ? Number(r.reward_id) : undefined,
id: r.id != null ? Number(r.id) : undefined,
grid_number: gn,
real_ev: r.real_ev,
ui_text: r.ui_text,
remark: r.remark,
weight_clockwise: 1,
weight_counterclockwise: normalizeWeightValue(r.weight)
})
}
}
}
out[t] = Array.from(byGrid.values())
}
return out
}
function loadData() {
loading.value = true
api
.weightRatioListWithDirection()
.then((res: any) => {
grouped.value = parsePayload(res)
})
.catch(() => {
ElMessage.error('获取权重数据失败')
})
.finally(() => {
loading.value = false
})
}
/** 按 DiceReward 主键 id 收集:每条记录一条 { id, weight } */
function collectItems(): Array<{ id: number; weight: number }> {
const items: Array<{ id: number; weight: number }> = []
for (const t of TIER_KEYS) {
for (const row of getTierItems(t)) {
const w0 = isWeightDisabled(row, t) ? 10000 : getItemWeight(row, 'clockwise')
const w1 = isWeightDisabled(row, t) ? 10000 : getItemWeight(row, 'counterclockwise')
const rid0 = row.reward_id_clockwise != null ? row.reward_id_clockwise : 0
const rid1 = row.reward_id_counterclockwise != null ? row.reward_id_counterclockwise : 0
if (rid0 > 0) items.push({ id: rid0, weight: w0 })
if (rid1 > 0) items.push({ id: rid1, weight: w1 })
}
}
return items
}
function handleSubmit() {
const items = collectItems()
if (items.length === 0) {
ElMessage.info('没有可提交的配置')
return
}
submitting.value = true
api
.batchUpdateWeights(items)
.then(() => {
ElMessage.success('保存成功')
emit('success')
handleClose()
})
.catch((e: { message?: string }) => {
ElMessage.error(e?.message ?? '保存失败')
})
.finally(() => {
submitting.value = false
})
}
function handleClose() {
visible.value = false
}
watch(
() => props.modelValue,
(open) => {
if (open) {
loadData()
activeTier.value = 'T1'
}
}
)
</script>
<style lang="scss" scoped>
.chart-wrap {
margin-bottom: 12px;
}
.chart-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
> * {
flex: 1;
min-width: 200px;
}
}
.weight-sum {
margin-bottom: 12px;
font-size: 13px;
}
.weight-sum-t4t5 {
color: var(--el-text-color-secondary);
}
.global-tip {
margin-bottom: 12px;
padding: 10px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
line-height: 1.5;
}
.weight-table {
margin-top: 8px;
}
.weight-cell-vertical {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.weight-slider-wrap {
width: 100%;
min-width: 80px;
padding: 0 8px;
:deep(.weight-slider) {
width: 100%;
}
}
.weight-input-wrap {
display: flex;
align-items: center;
gap: 6px;
}
.empty-tip {
padding: 24px;
text-align: center;
color: var(--el-text-color-secondary);
}
.dialog-body {
min-height: 320px;
}
</style>

View File

@@ -0,0 +1,447 @@
<template>
<el-dialog
v-model="visible"
title="权重配比"
width="900px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<div class="global-tip">
配置<strong>奖励对照表dice_reward</strong>的权重一级按<strong>方向</strong>顺时针/逆时针二级按<strong>档位</strong>T1-T5各条权重 1-10000档位内按权重比抽取
</div>
<div v-loading="loading" class="dialog-body">
<!-- 一级方向二级档位放在各方向 pane 切换方向时二级能正常显示 -->
<el-tabs v-model="activeDirection" type="card" class="direction-tabs">
<el-tab-pane label="顺时针" name="0">
<el-tabs v-model="activeTier" type="card" class="tier-tabs">
<el-tab-pane v-for="t in tierKeys" :key="'cw-' + t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">该档位暂无配置数据</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<ArtBarChart
x-axis-name="点数"
:x-axis-data="getTierChartLabels(t)"
:data="getTierChartDataForCurrentDirection(t)"
height="180px"
/>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
当前档位权重合计<strong>{{ getTierSumForCurrentDirection(t) }}</strong>
各条 1-10000档位内按权重比抽取和不限制
</div>
<div class="weight-sum weight-sum-t4t5" v-else>T4T5 仅单一结果无需配置权重</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column label="点数(grid_number)" prop="grid_number" width="110" align="center" show-overflow-tooltip />
<el-table-column label="结束索引(id)" prop="id" width="90" align="center" show-overflow-tooltip />
<el-table-column label="实际中奖金额" prop="real_ev" width="90" align="center" show-overflow-tooltip />
<el-table-column label="显示文本" prop="ui_text" min-width="70" align="center" show-overflow-tooltip />
<el-table-column label="备注" prop="remark" min-width="70" align="center" show-overflow-tooltip />
<el-table-column :label="currentDirectionLabel + ' 权重(1-10000)'" min-width="200" align="center">
<template #default="{ row }">
<div class="weight-cell-vertical">
<div class="weight-slider-wrap">
<el-slider
:model-value="getItemWeightForCurrentDirection(row)"
:min="1"
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="(v: number | number[]) => setItemWeightForCurrentDirection(t, row, Array.isArray(v) ? (v[0] ?? 1) : (v ?? 1))"
/>
</div>
<div class="weight-input-wrap">
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) <= 1"
@click="setItemWeightForCurrentDirection(t, row, Math.max(1, getItemWeightForCurrentDirection(row) - 1))"
></el-button>
<el-input-number
:model-value="getItemWeightForCurrentDirection(row)"
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@update:model-value="(v: number | string | undefined) => setItemWeightForCurrentDirection(t, row, typeof v === 'number' && !Number.isNaN(v) ? v : Number(v) || 1)"
/>
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) >= 10000"
@click="setItemWeightForCurrentDirection(t, row, Math.min(10000, getItemWeightForCurrentDirection(row) + 1))"
></el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</template>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<el-tab-pane label="逆时针 (direction=1)" name="1">
<el-tabs v-model="activeTier" type="card" class="tier-tabs">
<el-tab-pane v-for="t in tierKeys" :key="'ccw-' + t" :label="t" :name="t">
<div v-if="getTierItems(t).length === 0" class="empty-tip">该档位暂无配置数据</div>
<template v-else>
<div class="chart-wrap" v-if="t !== 'T4' && t !== 'T5'">
<ArtBarChart
x-axis-name="点数"
:x-axis-data="getTierChartLabels(t)"
:data="getTierChartDataForCurrentDirection(t)"
height="180px"
/>
</div>
<div class="weight-sum" v-if="t !== 'T4' && t !== 'T5'">
当前档位权重合计:<strong>{{ getTierSumForCurrentDirection(t) }}</strong>
(各条 1-10000档位内按权重比抽取和不限制
</div>
<div class="weight-sum weight-sum-t4t5" v-else>T4、T5 仅单一结果,无需配置权重。</div>
<el-table :data="getTierItems(t)" border size="small" class="weight-table">
<el-table-column label="点数(grid_number)" prop="grid_number" width="110" align="center" show-overflow-tooltip />
<el-table-column label="结束索引(id)" prop="id" width="90" align="center" show-overflow-tooltip />
<el-table-column label="实际中奖金额" prop="real_ev" width="90" align="center" show-overflow-tooltip />
<el-table-column label="显示文本" prop="ui_text" min-width="70" align="center" show-overflow-tooltip />
<el-table-column label="备注" prop="remark" min-width="70" align="center" show-overflow-tooltip />
<el-table-column :label="currentDirectionLabel + ' 权重(1-10000)'" min-width="200" align="center">
<template #default="{ row }">
<div class="weight-cell-vertical">
<div class="weight-slider-wrap">
<el-slider
:model-value="getItemWeightForCurrentDirection(row)"
:min="1"
:max="10000"
:step="1"
size="small"
:disabled="isWeightDisabled(row, t)"
class="weight-slider"
@update:model-value="(v: number | number[]) => setItemWeightForCurrentDirection(t, row, Array.isArray(v) ? (v[0] ?? 1) : (v ?? 1))"
/>
</div>
<div class="weight-input-wrap">
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) <= 1"
@click="setItemWeightForCurrentDirection(t, row, Math.max(1, getItemWeightForCurrentDirection(row) - 1))"
></el-button>
<el-input-number
:model-value="getItemWeightForCurrentDirection(row)"
:min="1"
:max="10000"
:step="1"
:disabled="isWeightDisabled(row, t)"
controls-position="right"
size="small"
class="weight-input"
@update:model-value="(v: number | string | undefined) => setItemWeightForCurrentDirection(t, row, typeof v === 'number' && !Number.isNaN(v) ? v : Number(v) || 1)"
/>
<el-button
type="primary"
link
:disabled="isWeightDisabled(row, t) || getItemWeightForCurrentDirection(row) >= 10000"
@click="setItemWeightForCurrentDirection(t, row, Math.min(10000, getItemWeightForCurrentDirection(row) + 1))"
></el-button>
</div>
</div>
</template>
</el-table-column>
</el-table>
</template>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/reward/index'
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus'
const TIER_KEYS = ['T1', 'T2', 'T3', 'T4', 'T5'] as const
type DirectionKey = 'clockwise' | 'counterclockwise'
/** 当前方向下的行reward_id=DiceReward 主键(用于提交更新)id=end_index 展示用weight 为当前方向权重 */
interface WeightRow {
reward_id?: number
id?: number
grid_number?: number
real_ev?: number
ui_text?: string
remark?: string
weight: number
}
/** 按档位、方向分组tier -> { '0': 顺时针行列表, '1': 逆时针行列表 } */
type GroupedByTierDirection = Record<string, { '0': WeightRow[]; '1': WeightRow[] }>
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): 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 activeDirection = ref<'0' | '1'>('0')
const activeTier = ref('T1')
const submitting = ref(false)
const loading = ref(false)
const grouped = ref<GroupedByTierDirection>({
T1: { '0': [], '1': [] },
T2: { '0': [], '1': [] },
T3: { '0': [], '1': [] },
T4: { '0': [], '1': [] },
T5: { '0': [], '1': [] }
})
const currentDirectionLabel = computed(() =>
activeDirection.value === '0' ? '顺时针' : '逆时针'
)
const tierKeys = TIER_KEYS
function getTierItems(tier: string): WeightRow[] {
const dir = activeDirection.value
const tierData = grouped.value[tier]
if (!tierData || !tierData[dir]) return []
return tierData[dir]
}
function getTierChartLabels(tier: string): string[] {
return getTierItems(tier).map((r) => String(r.grid_number ?? ''))
}
function getTierChartDataForCurrentDirection(tier: string): number[] {
return getTierItems(tier).map((r) => toWeightPrecision(r.weight ?? 1))
}
function getTierSumForCurrentDirection(tier: string): number {
return getTierItems(tier).reduce((s, r) => s + toWeightPrecision(r.weight ?? 1), 0)
}
function getItemWeightForCurrentDirection(row: WeightRow): number {
const w = row.weight
const num = typeof w === 'number' && !Number.isNaN(w) ? w : Number(w)
if (Number.isNaN(num)) return 1
return toWeightPrecision(num)
}
function toWeightPrecision(value: number): number {
return Math.max(1, Math.min(10000, Math.floor(value)))
}
function setItemWeightForCurrentDirection(tier: string, row: WeightRow, value: number) {
const v = toWeightPrecision(value)
const dir = activeDirection.value
const tierData = grouped.value[tier]
if (!tierData || !tierData[dir]) return
const list = tierData[dir]
const rid = row.reward_id != null ? row.reward_id : undefined
list.forEach((r) => {
if (r === row || (rid != null && r.reward_id != null && r.reward_id === rid)) r.weight = v
})
grouped.value[tier][dir] = [...list]
}
function isWeightDisabled(row: WeightRow, tier: string): boolean {
if (tier === 'T4' || tier === 'T5') return true
return false
}
function normalizeWeightValue(v: unknown): number {
const num = typeof v === 'number' && !Number.isNaN(v) ? v : Number(v)
if (Number.isNaN(num)) return 1
return Math.max(1, Math.min(10000, Math.floor(num)))
}
/** 兼容后端返回 tier -> { 0: [], 1: [] },每行含 reward_id, id(end_index), grid_number, ui_text, real_ev, remark, weight */
function parsePayload(res: any): GroupedByTierDirection {
const empty: GroupedByTierDirection = {
T1: { '0': [], '1': [] },
T2: { '0': [], '1': [] },
T3: { '0': [], '1': [] },
T4: { '0': [], '1': [] },
T5: { '0': [], '1': [] }
}
if (!res || typeof res !== 'object') return empty
const raw = res?.data ?? res
if (!raw || typeof raw !== 'object') return empty
const out = { ...empty }
for (const t of TIER_KEYS) {
const tierData = raw[t]
if (!tierData || typeof tierData !== 'object') continue
out[t] = {
'0': Array.isArray(tierData[0])
? tierData[0].map((r: any) => ({
...r,
reward_id: r.reward_id != null ? Number(r.reward_id) : undefined,
weight: normalizeWeightValue(r.weight)
}))
: [],
'1': Array.isArray(tierData[1])
? tierData[1].map((r: any) => ({
...r,
reward_id: r.reward_id != null ? Number(r.reward_id) : undefined,
weight: normalizeWeightValue(r.weight)
}))
: []
}
}
return out
}
function loadData() {
loading.value = true
api
.weightRatioListWithDirection()
.then((res: any) => {
grouped.value = parsePayload(res)
})
.catch(() => {
ElMessage.error('获取权重数据失败')
})
.finally(() => {
loading.value = false
})
}
/** 按 DiceReward 主键 id 收集:每条记录一条 { id, weight },直接用于后端按 id 更新 */
function collectItems(): Array<{ id: number; weight: number }> {
const items: Array<{ id: number; weight: number }> = []
for (const t of TIER_KEYS) {
const tierData = grouped.value[t]
if (!tierData) continue
for (const dir of ['0', '1'] as const) {
const list = tierData[dir] ?? []
for (const row of list) {
const rid = row.reward_id != null ? Number(row.reward_id) : 0
if (rid <= 0) continue
const w = isWeightDisabled(row, t) ? 10000 : toWeightPrecision(row.weight ?? 1)
items.push({ id: rid, weight: w })
}
}
}
return items
}
function handleSubmit() {
const items = collectItems()
if (items.length === 0) {
ElMessage.info('没有可提交的配置')
return
}
submitting.value = true
api
.batchUpdateWeights(items)
.then(() => {
ElMessage.success('保存成功')
emit('success')
handleClose()
})
.catch((e: { message?: string }) => {
ElMessage.error(e?.message ?? '保存失败')
})
.finally(() => {
submitting.value = false
})
}
function handleClose() {
visible.value = false
}
watch(
() => props.modelValue,
(open) => {
if (open) {
loadData()
activeDirection.value = '0'
activeTier.value = 'T1'
}
}
)
</script>
<style lang="scss" scoped>
.direction-tabs {
margin-bottom: 8px;
}
.tier-tabs {
margin-top: 8px;
}
.chart-wrap {
margin-bottom: 12px;
}
.weight-sum {
margin-bottom: 12px;
font-size: 13px;
}
.weight-sum-t4t5 {
color: var(--el-text-color-secondary);
}
.global-tip {
margin-bottom: 12px;
padding: 10px 12px;
font-size: 13px;
color: var(--el-text-color-regular);
background: var(--el-fill-color-light);
border-radius: 6px;
line-height: 1.5;
}
.weight-table {
margin-top: 8px;
}
.weight-cell-vertical {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.weight-slider-wrap {
width: 100%;
min-width: 80px;
padding: 0 8px;
.weight-slider {
width: 100%;
}
}
.weight-input-wrap {
display: flex;
align-items: center;
gap: 6px;
}
.empty-tip {
padding: 24px;
text-align: center;
color: var(--el-text-color-secondary);
}
.dialog-body {
min-height: 320px;
}
</style>