yumi-admin/apps/src/views/resident-activity/voice-room-rocket.vue
2026-05-14 18:02:09 +08:00

847 lines
25 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.

<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
Button,
Card,
Input,
InputNumber,
Select,
Space,
Spin,
Switch,
Table,
Tabs,
TabPane,
Tag,
TextArea,
message,
} from 'antdv-next';
import {
getVoiceRoomRocketConfig,
getVoiceRoomRocketLevelConfigs,
getVoiceRoomRocketRewardConfigs,
saveVoiceRoomRocketConfig,
saveVoiceRoomRocketLevelConfigs,
saveVoiceRoomRocketRewardConfigs,
} from '#/api/legacy/resident-activity';
import { getAllowedSysOrigins } from '#/views/system/shared';
defineOptions({ name: 'ResidentVoiceRoomRocketPage' });
const RIYADH_TIMEZONE = 'Asia/Riyadh';
const DEFAULT_MAX_LEVEL = 6;
type LevelRow = {
enabled: boolean;
id?: number | string;
level: number;
needEnergy: number;
progressBarUrl: string;
rocketAnimationUrl: string;
rocketIconUrl: string;
shakeThresholdPercent: string;
sort: number;
};
type RewardRow = {
enabled: boolean;
expireDays?: null | number;
id?: number | string;
localKey: string;
level: number;
rewardAmount: number;
rewardCover: string;
rewardItemId?: null | number | string;
rewardName: string;
rewardScene: string;
rewardType: string;
sort: number;
weight: number;
};
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const selectedSysOrigin = ref('');
const activeTab = ref('config');
const rewardLevel = ref(1);
const configLoading = ref(false);
const levelLoading = ref(false);
const rewardLoading = ref(false);
const configSaving = ref(false);
const levelSaving = ref(false);
const rewardSaving = ref(false);
const configForm = reactive({
broadcastDurationSeconds: 7,
configured: false,
enabled: false,
energyRuleJsonText: '{}',
id: '',
inRoomRewardDelaySeconds: 10,
maxLevel: DEFAULT_MAX_LEVEL,
rankingTopLimit: 100,
rewardRecordKeepDays: 35,
ruleTextText: '{}',
timezone: RIYADH_TIMEZONE,
updateTime: '',
});
const levelRows = ref<LevelRow[]>([]);
const rewardRows = ref<RewardRow[]>([]);
const sceneOptions = [
{ label: '贡献第一', value: 'TOP1' },
{ label: '点火人', value: 'IGNITE' },
{ label: '在房用户', value: 'IN_ROOM' },
];
const rewardTypeOptions = [
{ label: '金币', value: 'GOLD' },
{ label: '坐骑', value: 'MOUNT' },
{ label: '头像框', value: 'AVATAR_FRAME' },
{ label: '气泡', value: 'CHAT_BUBBLE' },
{ label: 'VIP', value: 'VIP_CARD' },
{ label: '礼物', value: 'GIFT' },
{ label: '徽章', value: 'BADGE' },
];
const levelOptions = computed(() =>
Array.from({ length: configForm.maxLevel || DEFAULT_MAX_LEVEL }, (_, index) => ({
label: `Lv.${index + 1}`,
value: index + 1,
})),
);
const statusMeta = computed(() => {
if (!configForm.configured) {
return { color: 'warning', text: '未配置' };
}
return configForm.enabled
? { color: 'success', text: '已启用' }
: { color: 'default', text: '已关闭' };
});
const levelColumns = [
{ dataIndex: 'level', key: 'level', title: '等级', width: 90 },
{ dataIndex: 'needEnergy', key: 'needEnergy', title: '所需能量', width: 160 },
{ dataIndex: 'shakeThresholdPercent', key: 'shakeThresholdPercent', title: '晃动阈值(%)', width: 160 },
{ dataIndex: 'rocketIconUrl', key: 'rocketIconUrl', title: '火箭图标', width: 260 },
{ dataIndex: 'rocketAnimationUrl', key: 'rocketAnimationUrl', title: '发射动画', width: 260 },
{ dataIndex: 'progressBarUrl', key: 'progressBarUrl', title: '进度条资源', width: 260 },
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 100 },
];
const rewardColumns = [
{ dataIndex: 'rewardScene', key: 'rewardScene', title: '奖励场景', width: 150 },
{ dataIndex: 'rewardType', key: 'rewardType', title: '奖励类型', width: 150 },
{ dataIndex: 'rewardItemId', key: 'rewardItemId', title: '资源ID', width: 170 },
{ dataIndex: 'rewardName', key: 'rewardName', title: '奖励名称', width: 180 },
{ dataIndex: 'rewardCover', key: 'rewardCover', title: '封面URL', width: 220 },
{ dataIndex: 'rewardAmount', key: 'rewardAmount', title: '数量/金币/天数', width: 150 },
{ dataIndex: 'expireDays', key: 'expireDays', title: '有效天数', width: 130 },
{ dataIndex: 'weight', key: 'weight', title: '权重', width: 110 },
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 100 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
];
function createDefaultLevel(level: number): LevelRow {
return {
enabled: true,
level,
needEnergy: level * 100,
progressBarUrl: '',
rocketAnimationUrl: '',
rocketIconUrl: '',
shakeThresholdPercent: '95.0000',
sort: level,
};
}
function stringifyJSON(value: unknown) {
if (value === null || value === undefined || value === '') {
return '{}';
}
if (typeof value === 'string') {
return value.trim() || '{}';
}
return JSON.stringify(value, null, 2);
}
function parseJSONText(value: string, label: string) {
const raw = String(value || '').trim();
if (!raw) {
return {};
}
try {
return JSON.parse(raw);
} catch {
message.warning(`${label} 必须是合法 JSON`);
return undefined;
}
}
function toNumber(value: unknown, fallback = 0) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function toOptionalNumber(value: unknown) {
if (value === null || value === undefined || value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function toOptionalString(value: unknown) {
const text = String(value ?? '').trim();
return text || undefined;
}
function normalizeConfig(value: Record<string, any> | null | undefined) {
const next = value || {};
configForm.broadcastDurationSeconds = toNumber(
next.broadcastDurationSeconds,
7,
);
configForm.configured = Boolean(next.configured);
configForm.enabled = Boolean(next.enabled);
configForm.energyRuleJsonText = stringifyJSON(next.energyRuleJson);
configForm.id = String(next.id || '');
configForm.inRoomRewardDelaySeconds = toNumber(
next.inRoomRewardDelaySeconds,
10,
);
configForm.maxLevel = toNumber(next.maxLevel, DEFAULT_MAX_LEVEL);
configForm.rankingTopLimit = toNumber(next.rankingTopLimit, 100);
configForm.rewardRecordKeepDays = toNumber(next.rewardRecordKeepDays, 35);
configForm.ruleTextText = stringifyJSON(next.ruleText);
configForm.timezone = RIYADH_TIMEZONE;
configForm.updateTime = String(next.updateTime || '');
}
function normalizeLevelRows(rows: Array<Record<string, any>> | undefined) {
const source = Array.isArray(rows) ? rows : [];
const byLevel = new Map<number, Record<string, any>>();
source.forEach((item) => {
byLevel.set(toNumber(item.level), item);
});
levelRows.value = Array.from({ length: DEFAULT_MAX_LEVEL }, (_, index) => {
const level = index + 1;
const row = byLevel.get(level) || {};
return {
...createDefaultLevel(level),
enabled: row.enabled === undefined ? true : Boolean(row.enabled),
id: row.id,
needEnergy: toNumber(row.needEnergy, level * 100),
progressBarUrl: String(row.progressBarUrl || ''),
rocketAnimationUrl: String(row.rocketAnimationUrl || ''),
rocketIconUrl: String(row.rocketIconUrl || ''),
shakeThresholdPercent: String(row.shakeThresholdPercent || '95.0000'),
sort: toNumber(row.sort, level),
};
});
}
function normalizeRewardRows(rows: Array<Record<string, any>> | undefined) {
rewardRows.value = (Array.isArray(rows) ? rows : []).map((row, index) => ({
enabled: row.enabled === undefined ? true : Boolean(row.enabled),
expireDays: toOptionalNumber(row.expireDays) ?? null,
id: row.id,
level: toNumber(row.level, rewardLevel.value),
localKey: `${row.id || 'new'}-${index}-${Date.now()}`,
rewardAmount: toNumber(row.rewardAmount, 1),
rewardCover: String(row.rewardCover || ''),
rewardItemId: row.rewardItemId ?? null,
rewardName: String(row.rewardName || ''),
rewardScene: String(row.rewardScene || 'TOP1'),
rewardType: String(row.rewardType || 'GOLD'),
sort: toNumber(row.sort, index + 1),
weight: toNumber(row.weight, 1),
}));
}
async function loadConfig() {
if (!selectedSysOrigin.value) {
return;
}
configLoading.value = true;
try {
const result = await getVoiceRoomRocketConfig(selectedSysOrigin.value);
normalizeConfig(result);
} finally {
configLoading.value = false;
}
}
async function loadLevels() {
if (!selectedSysOrigin.value) {
return;
}
levelLoading.value = true;
try {
const result = await getVoiceRoomRocketLevelConfigs(selectedSysOrigin.value);
normalizeLevelRows(result);
} finally {
levelLoading.value = false;
}
}
async function loadRewards() {
if (!selectedSysOrigin.value) {
return;
}
rewardLoading.value = true;
try {
const result = await getVoiceRoomRocketRewardConfigs({
level: rewardLevel.value,
sysOrigin: selectedSysOrigin.value,
});
normalizeRewardRows(result);
} finally {
rewardLoading.value = false;
}
}
async function loadAll() {
await Promise.all([loadConfig(), loadLevels(), loadRewards()]);
}
async function saveConfig() {
const energyRuleJson = parseJSONText(
configForm.energyRuleJsonText,
'能量规则',
);
if (energyRuleJson === undefined) {
return;
}
const ruleText = parseJSONText(configForm.ruleTextText, '规则说明');
if (ruleText === undefined) {
return;
}
configSaving.value = true;
try {
const result = await saveVoiceRoomRocketConfig({
broadcastDurationSeconds: configForm.broadcastDurationSeconds,
enabled: configForm.enabled,
energyRuleJson,
inRoomRewardDelaySeconds: configForm.inRoomRewardDelaySeconds,
maxLevel: DEFAULT_MAX_LEVEL,
rankingTopLimit: configForm.rankingTopLimit,
rewardRecordKeepDays: configForm.rewardRecordKeepDays,
ruleText,
sysOrigin: selectedSysOrigin.value,
timezone: RIYADH_TIMEZONE,
});
normalizeConfig(result);
message.success('保存成功');
} finally {
configSaving.value = false;
}
}
async function saveLevels() {
levelSaving.value = true;
try {
const result = await saveVoiceRoomRocketLevelConfigs({
levels: levelRows.value.map((row) => ({
enabled: row.enabled,
id: row.id,
level: row.level,
needEnergy: toNumber(row.needEnergy, row.level * 100),
progressBarUrl: row.progressBarUrl,
rocketAnimationUrl: row.rocketAnimationUrl,
rocketIconUrl: row.rocketIconUrl,
shakeThresholdPercent: row.shakeThresholdPercent,
sort: toNumber(row.sort, row.level),
})),
sysOrigin: selectedSysOrigin.value,
});
normalizeLevelRows(result);
message.success('保存成功');
} finally {
levelSaving.value = false;
}
}
function addRewardRow(scene = 'TOP1') {
rewardRows.value.push({
enabled: true,
expireDays: null,
level: rewardLevel.value,
localKey: `new-${Date.now()}-${rewardRows.value.length}`,
rewardAmount: 1,
rewardCover: '',
rewardItemId: null,
rewardName: scene === 'TOP1' ? '金币' : '',
rewardScene: scene,
rewardType: 'GOLD',
sort: rewardRows.value.length + 1,
weight: 1,
});
}
function removeRewardRow(index: number) {
rewardRows.value.splice(index, 1);
}
function handleRewardTypeChange(row: Record<string, any>) {
if (row.rewardType === 'GOLD') {
row.rewardItemId = null;
row.expireDays = null;
if (!row.rewardName) {
row.rewardName = '金币';
}
}
}
function validateRewards() {
for (const row of rewardRows.value) {
if (!row.rewardScene) {
message.warning('请选择奖励场景');
return false;
}
if (!row.rewardType) {
message.warning('请选择奖励类型');
return false;
}
if (!String(row.rewardName || '').trim()) {
message.warning('请输入奖励名称');
return false;
}
if (row.rewardType !== 'GOLD' && !String(row.rewardItemId || '').trim()) {
message.warning('非金币奖励必须填写资源ID');
return false;
}
}
return true;
}
async function saveRewards() {
if (!validateRewards()) {
return;
}
rewardSaving.value = true;
try {
const result = await saveVoiceRoomRocketRewardConfigs({
level: rewardLevel.value,
rewards: rewardRows.value.map((row) => ({
enabled: row.enabled,
expireDays: toOptionalNumber(row.expireDays),
id: row.id,
level: rewardLevel.value,
rewardAmount: toNumber(row.rewardAmount, 1),
rewardCover: row.rewardCover,
rewardItemId:
row.rewardType === 'GOLD' ? undefined : toOptionalString(row.rewardItemId),
rewardName: row.rewardName.trim(),
rewardScene: row.rewardScene,
rewardType: row.rewardType,
sort: toNumber(row.sort, 1),
weight: toNumber(row.weight, 1),
})),
sysOrigin: selectedSysOrigin.value,
});
normalizeRewardRows(result);
message.success('保存成功');
} finally {
rewardSaving.value = false;
}
}
watch(
sysOriginOptions,
(options) => {
if (!selectedSysOrigin.value && options[0]?.value) {
selectedSysOrigin.value = String(options[0].value);
}
},
{ immediate: true },
);
watch(
selectedSysOrigin,
() => {
void loadAll();
},
{ immediate: true },
);
watch(rewardLevel, () => {
void loadRewards();
});
</script>
<template>
<Page title="火箭">
<div class="voice-room-rocket-page">
<Card>
<Space wrap>
<span>系统</span>
<Select
v-model:value="selectedSysOrigin"
:options="sysOriginOptions"
style="width: 180px"
/>
<Tag :color="statusMeta.color">{{ statusMeta.text }}</Tag>
<Tag color="processing">利雅得时间每日 00:00 重置</Tag>
<span v-if="configForm.updateTime" class="muted-text">
更新时间:{{ configForm.updateTime }}
</span>
</Space>
</Card>
<Tabs v-model:active-key="activeTab">
<TabPane key="config" tab="基础配置">
<Spin :spinning="configLoading">
<Card>
<div class="config-grid">
<label class="field-row">
<span>启用火箭</span>
<Switch v-model:checked="configForm.enabled" />
</label>
<label class="field-row">
<span>活动时区</span>
<Input
v-model:value="configForm.timezone"
class="field-control"
disabled
/>
</label>
<label class="field-row">
<span>最大等级</span>
<InputNumber
v-model:value="configForm.maxLevel"
:min="1"
:max="6"
:precision="0"
class="field-control"
disabled
/>
</label>
<label class="field-row">
<span>榜单人数</span>
<InputNumber
v-model:value="configForm.rankingTopLimit"
:min="1"
:max="100"
:precision="0"
class="field-control"
/>
</label>
<label class="field-row">
<span>发奖记录保留天数</span>
<InputNumber
v-model:value="configForm.rewardRecordKeepDays"
:min="1"
:precision="0"
class="field-control"
/>
</label>
<label class="field-row">
<span>发射广播秒数</span>
<InputNumber
v-model:value="configForm.broadcastDurationSeconds"
:min="1"
:precision="0"
class="field-control"
/>
</label>
<label class="field-row">
<span>在房奖励延迟秒数</span>
<InputNumber
v-model:value="configForm.inRoomRewardDelaySeconds"
:min="1"
:precision="0"
class="field-control"
/>
</label>
<label class="field-row field-wide">
<span>能量规则 JSON</span>
<TextArea
v-model:value="configForm.energyRuleJsonText"
:rows="4"
class="field-control"
/>
</label>
<label class="field-row field-wide">
<span>规则说明 JSON</span>
<TextArea
v-model:value="configForm.ruleTextText"
:rows="4"
class="field-control"
/>
</label>
</div>
<div class="actions">
<Space>
<Button @click="loadConfig">刷新</Button>
<Button type="primary" :loading="configSaving" @click="saveConfig">
保存基础配置
</Button>
</Space>
</div>
</Card>
</Spin>
</TabPane>
<TabPane key="levels" tab="等级进度">
<Card>
<Table
:columns="levelColumns"
:data-source="levelRows"
:loading="levelLoading"
:pagination="false"
:scroll="{ x: 1420 }"
row-key="level"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'level'">
<Tag color="blue">Lv.{{ record.level }}</Tag>
</template>
<template v-else-if="column.key === 'needEnergy'">
<InputNumber
v-model:value="record.needEnergy"
:min="1"
:precision="0"
class="table-number"
/>
</template>
<template v-else-if="column.key === 'shakeThresholdPercent'">
<Input
v-model:value="record.shakeThresholdPercent"
class="table-input"
/>
</template>
<template v-else-if="column.key === 'rocketIconUrl'">
<Input v-model:value="record.rocketIconUrl" class="table-input" />
</template>
<template v-else-if="column.key === 'rocketAnimationUrl'">
<Input
v-model:value="record.rocketAnimationUrl"
class="table-input"
/>
</template>
<template v-else-if="column.key === 'progressBarUrl'">
<Input v-model:value="record.progressBarUrl" class="table-input" />
</template>
<template v-else-if="column.key === 'enabled'">
<Switch v-model:checked="record.enabled" />
</template>
<template v-else-if="column.key === 'sort'">
<InputNumber
v-model:value="record.sort"
:min="1"
:precision="0"
class="table-number"
/>
</template>
</template>
</Table>
<div class="actions">
<Space>
<Button @click="loadLevels">刷新</Button>
<Button type="primary" :loading="levelSaving" @click="saveLevels">
保存等级进度
</Button>
</Space>
</div>
</Card>
</TabPane>
<TabPane key="rewards" tab="奖励配置">
<Card>
<Space class="reward-toolbar" wrap>
<span>等级</span>
<Select
v-model:value="rewardLevel"
:options="levelOptions"
style="width: 120px"
/>
<Button @click="loadRewards">刷新</Button>
<Button @click="addRewardRow('TOP1')">添加贡献第一</Button>
<Button @click="addRewardRow('IGNITE')">添加点火人</Button>
<Button @click="addRewardRow('IN_ROOM')">添加在房用户</Button>
<Button type="primary" :loading="rewardSaving" @click="saveRewards">
保存当前等级奖励
</Button>
</Space>
<Table
:columns="rewardColumns"
:data-source="rewardRows"
:loading="rewardLoading"
:pagination="false"
:scroll="{ x: 1660 }"
row-key="localKey"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'rewardScene'">
<Select
v-model:value="record.rewardScene"
:options="sceneOptions"
class="table-select"
/>
</template>
<template v-else-if="column.key === 'rewardType'">
<Select
v-model:value="record.rewardType"
:options="rewardTypeOptions"
class="table-select"
@change="() => handleRewardTypeChange(record)"
/>
</template>
<template v-else-if="column.key === 'rewardItemId'">
<Input
v-model:value="record.rewardItemId"
:disabled="record.rewardType === 'GOLD'"
class="table-input-small"
/>
</template>
<template v-else-if="column.key === 'rewardName'">
<Input v-model:value="record.rewardName" class="table-input-small" />
</template>
<template v-else-if="column.key === 'rewardCover'">
<Input v-model:value="record.rewardCover" class="table-input" />
</template>
<template v-else-if="column.key === 'rewardAmount'">
<InputNumber
v-model:value="record.rewardAmount"
:min="1"
:precision="0"
class="table-number"
/>
</template>
<template v-else-if="column.key === 'expireDays'">
<InputNumber
v-model:value="record.expireDays"
:disabled="record.rewardType === 'GOLD'"
:min="0"
:precision="0"
class="table-number"
/>
</template>
<template v-else-if="column.key === 'weight'">
<InputNumber
v-model:value="record.weight"
:min="0"
:precision="0"
class="table-number"
/>
</template>
<template v-else-if="column.key === 'enabled'">
<Switch v-model:checked="record.enabled" />
</template>
<template v-else-if="column.key === 'sort'">
<InputNumber
v-model:value="record.sort"
:min="1"
:precision="0"
class="table-number"
/>
</template>
<template v-else-if="column.key === 'actions'">
<Button danger size="small" @click="removeRewardRow(index)">
删除
</Button>
</template>
</template>
</Table>
<div class="reward-summary">
<Tag v-for="scene in sceneOptions" :key="scene.value">
{{ scene.label }}
{{
rewardRows.filter((item) => item.rewardScene === scene.value).length
}}
</Tag>
<Tag v-for="type in rewardTypeOptions" :key="type.value">
{{ type.label }}
{{ rewardRows.filter((item) => item.rewardType === type.value).length }}
</Tag>
</div>
</Card>
</TabPane>
</Tabs>
</div>
</Page>
</template>
<style scoped>
.voice-room-rocket-page {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.field-row {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: #4b5563;
}
.field-wide {
grid-column: 1 / -1;
}
.field-control {
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.muted-text {
color: #8c8c8c;
}
.reward-toolbar {
margin-bottom: 16px;
}
.reward-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.table-input {
width: 240px;
}
.table-input-small,
.table-select {
width: 140px;
}
.table-number {
width: 110px;
}
</style>