1245 lines
37 KiB
Vue
1245 lines
37 KiB
Vue
<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';
|
||
|
||
import ResourceSelectDrawer from './components/resource-select-drawer.vue';
|
||
|
||
defineOptions({ name: 'ResidentVoiceRoomRocketPage' });
|
||
|
||
const RIYADH_TIMEZONE = 'Asia/Riyadh';
|
||
const DEFAULT_MAX_LEVEL = 6;
|
||
|
||
type LevelRow = {
|
||
enabled: boolean;
|
||
id?: number | string;
|
||
level: number;
|
||
needEnergy: number;
|
||
previewRocketUrl: 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;
|
||
rewardCopies: number;
|
||
sort: number;
|
||
weight: number;
|
||
};
|
||
|
||
type ResourceOption = {
|
||
amount?: number | string;
|
||
cover: string;
|
||
id: number | string;
|
||
name: string;
|
||
remark?: string;
|
||
sourceUrl: string;
|
||
type: string;
|
||
};
|
||
|
||
type LevelResourceField =
|
||
| 'previewRocketUrl'
|
||
| 'rocketAnimationUrl'
|
||
| 'rocketIconUrl';
|
||
|
||
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 resourcePickerOpen = ref(false);
|
||
const resourcePickerTitle = ref('选择资源');
|
||
const resourcePickerDefaultType = ref('ALL_PROPS');
|
||
const resourcePickerFixedType = ref(false);
|
||
const resourcePickerTarget = ref<
|
||
| {
|
||
field: LevelResourceField;
|
||
kind: 'level';
|
||
row: Record<string, any>;
|
||
}
|
||
| {
|
||
kind: 'reward';
|
||
row: Record<string, any>;
|
||
}
|
||
| null
|
||
>(null);
|
||
|
||
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' },
|
||
{ label: '轮空', value: 'NONE' },
|
||
];
|
||
|
||
const levelOptions = computed(() =>
|
||
Array.from({ length: configForm.maxLevel || DEFAULT_MAX_LEVEL }, (_, index) => ({
|
||
label: `Lv.${index + 1}`,
|
||
value: index + 1,
|
||
})),
|
||
);
|
||
|
||
function rewardTypeOptionsForRow(row: Record<string, any>) {
|
||
return row.rewardScene === 'IN_ROOM'
|
||
? rewardTypeOptions
|
||
: rewardTypeOptions.filter((item) => item.value !== 'NONE');
|
||
}
|
||
|
||
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: 'previewRocketUrl',
|
||
key: 'previewRocketUrl',
|
||
title: '预览火箭',
|
||
width: 260,
|
||
},
|
||
{ dataIndex: 'rocketIconUrl', key: 'rocketIconUrl', title: '火箭图标', width: 260 },
|
||
{
|
||
dataIndex: 'rocketAnimationUrl',
|
||
key: 'rocketAnimationUrl',
|
||
title: '发射动画',
|
||
width: 260,
|
||
},
|
||
{ dataIndex: 'shakeThresholdPercent', key: 'shakeThresholdPercent', title: '晃动阈值(%)', width: 160 },
|
||
{ 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: '资源', width: 220 },
|
||
{ dataIndex: 'rewardName', key: 'rewardName', title: '奖励名称', width: 180 },
|
||
{ dataIndex: 'rewardCover', key: 'rewardCover', title: '封面', width: 220 },
|
||
{ dataIndex: 'rewardAmount', key: 'rewardAmount', title: '数量/金币/天数', width: 150 },
|
||
{ dataIndex: 'expireDays', key: 'expireDays', title: '有效天数', width: 130 },
|
||
{ dataIndex: 'rewardCopies', key: 'rewardCopies', title: '份数', width: 110 },
|
||
{ 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,
|
||
previewRocketUrl: '',
|
||
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),
|
||
previewRocketUrl: String(row.previewRocketUrl || ''),
|
||
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'),
|
||
rewardCopies: toNumber(row.rewardCopies, 1),
|
||
sort: toNumber(row.sort, index + 1),
|
||
weight: toNumber(row.weight, 1),
|
||
}));
|
||
}
|
||
|
||
function rewardResourceType(rewardType: string) {
|
||
const value = String(rewardType || '').trim();
|
||
const mapping: Record<string, string> = {
|
||
AVATAR_FRAME: 'AVATAR_FRAME',
|
||
BADGE: 'BADGE',
|
||
CHAT_BUBBLE: 'CHAT_BUBBLE',
|
||
GIFT: 'GIFT',
|
||
MOUNT: 'RIDE',
|
||
VIP_CARD: 'NOBLE_VIP',
|
||
};
|
||
return mapping[value] || '';
|
||
}
|
||
|
||
function rewardTypeText(rewardType: string) {
|
||
return (
|
||
rewardTypeOptions.find((item) => item.value === rewardType)?.label ||
|
||
rewardType ||
|
||
'资源'
|
||
);
|
||
}
|
||
|
||
function isRewardResourceDisabled(row: Record<string, any>) {
|
||
return row.rewardType === 'GOLD' || row.rewardType === 'NONE';
|
||
}
|
||
|
||
function openLevelResourcePicker(
|
||
row: Record<string, any>,
|
||
field: LevelResourceField,
|
||
) {
|
||
resourcePickerTarget.value = {
|
||
field,
|
||
kind: 'level',
|
||
row,
|
||
};
|
||
const titleMap: Record<LevelResourceField, string> = {
|
||
previewRocketUrl: '选择预览火箭资源',
|
||
rocketAnimationUrl: '选择发射动画资源',
|
||
rocketIconUrl: '选择火箭图标资源',
|
||
};
|
||
resourcePickerTitle.value = titleMap[field];
|
||
resourcePickerDefaultType.value = 'ALL_PROPS';
|
||
resourcePickerFixedType.value = false;
|
||
resourcePickerOpen.value = true;
|
||
}
|
||
|
||
function openRewardResourcePicker(row: Record<string, any>) {
|
||
if (isRewardResourceDisabled(row)) {
|
||
return;
|
||
}
|
||
const sourceType = rewardResourceType(row.rewardType);
|
||
if (!sourceType) {
|
||
return;
|
||
}
|
||
resourcePickerTarget.value = {
|
||
kind: 'reward',
|
||
row,
|
||
};
|
||
resourcePickerTitle.value = `选择${rewardTypeText(row.rewardType)}资源`;
|
||
resourcePickerDefaultType.value = sourceType;
|
||
resourcePickerFixedType.value = true;
|
||
resourcePickerOpen.value = true;
|
||
}
|
||
|
||
function clearLevelResource(
|
||
row: Record<string, any>,
|
||
field: LevelResourceField,
|
||
) {
|
||
row[field] = '';
|
||
}
|
||
|
||
function clearRewardResource(row: Record<string, any>) {
|
||
row.rewardItemId = null;
|
||
row.rewardCover = '';
|
||
if (row.rewardName !== '金币' && row.rewardName !== '轮空') {
|
||
row.rewardName = '';
|
||
}
|
||
}
|
||
|
||
function handleRewardSceneChange(row: Record<string, any>) {
|
||
if (row.rewardScene !== 'IN_ROOM' && row.rewardType === 'NONE') {
|
||
row.rewardType = 'GOLD';
|
||
handleRewardTypeChange(row);
|
||
}
|
||
row.rewardCopies = row.rewardScene === 'IN_ROOM' ? toNumber(row.rewardCopies, 1) : 1;
|
||
}
|
||
|
||
function handleResourceSelect(item: ResourceOption) {
|
||
const target = resourcePickerTarget.value;
|
||
if (!target) {
|
||
return;
|
||
}
|
||
if (target.kind === 'level') {
|
||
const value =
|
||
target.field === 'rocketAnimationUrl'
|
||
? String(item.sourceUrl || item.cover || '')
|
||
: String(item.cover || item.sourceUrl || '');
|
||
target.row[target.field] = value;
|
||
} else {
|
||
target.row.rewardItemId = item.id;
|
||
target.row.rewardName = item.name || String(item.id || '');
|
||
target.row.rewardCover = item.cover || item.sourceUrl || '';
|
||
}
|
||
resourcePickerOpen.value = false;
|
||
resourcePickerTarget.value = null;
|
||
}
|
||
|
||
function resourceUrlName(url: string) {
|
||
const text = String(url || '').trim();
|
||
if (!text) {
|
||
return '未选择';
|
||
}
|
||
return text;
|
||
}
|
||
|
||
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),
|
||
previewRocketUrl: row.previewRocketUrl,
|
||
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', rewardType = 'GOLD') {
|
||
const isNone = rewardType === 'NONE';
|
||
rewardRows.value.push({
|
||
enabled: true,
|
||
expireDays: null,
|
||
level: rewardLevel.value,
|
||
localKey: `new-${Date.now()}-${rewardRows.value.length}`,
|
||
rewardAmount: isNone ? 0 : 1,
|
||
rewardCover: '',
|
||
rewardItemId: null,
|
||
rewardName: isNone ? '轮空' : rewardType === 'GOLD' ? '金币' : '',
|
||
rewardScene: scene,
|
||
rewardType,
|
||
rewardCopies: 1,
|
||
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 === 'NONE') {
|
||
if (row.rewardScene !== 'IN_ROOM') {
|
||
row.rewardScene = 'IN_ROOM';
|
||
}
|
||
row.rewardItemId = null;
|
||
row.rewardCover = '';
|
||
row.expireDays = null;
|
||
row.rewardAmount = 0;
|
||
row.rewardName = '轮空';
|
||
return;
|
||
}
|
||
if (row.rewardType === 'GOLD') {
|
||
row.rewardItemId = null;
|
||
row.rewardCover = '';
|
||
row.expireDays = null;
|
||
if (!row.rewardName) {
|
||
row.rewardName = '金币';
|
||
}
|
||
return;
|
||
}
|
||
row.rewardItemId = null;
|
||
row.rewardCover = '';
|
||
if (row.rewardName === '金币' || 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 (row.rewardType === 'NONE' && row.rewardScene !== 'IN_ROOM') {
|
||
message.warning('轮空只能配置在房用户奖励');
|
||
return false;
|
||
}
|
||
if (row.rewardScene !== 'IN_ROOM') {
|
||
row.rewardCopies = 1;
|
||
} else if (toNumber(row.rewardCopies, 0) <= 0) {
|
||
message.warning('在房用户奖励份数必须大于 0');
|
||
return false;
|
||
}
|
||
if (!String(row.rewardName || '').trim()) {
|
||
message.warning('请输入奖励名称');
|
||
return false;
|
||
}
|
||
if (
|
||
row.rewardType !== 'GOLD' &&
|
||
row.rewardType !== 'NONE' &&
|
||
!String(row.rewardItemId || '').trim()
|
||
) {
|
||
message.warning('非金币奖励必须选择资源');
|
||
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:
|
||
row.rewardType === 'GOLD' || row.rewardType === 'NONE'
|
||
? undefined
|
||
: toOptionalNumber(row.expireDays),
|
||
id: row.id,
|
||
level: rewardLevel.value,
|
||
rewardAmount:
|
||
row.rewardType === 'NONE' ? 0 : toNumber(row.rewardAmount, 1),
|
||
rewardCover: row.rewardType === 'NONE' ? '' : row.rewardCover,
|
||
rewardItemId:
|
||
row.rewardType === 'GOLD' || row.rewardType === 'NONE'
|
||
? undefined
|
||
: toOptionalString(row.rewardItemId),
|
||
rewardName: row.rewardType === 'NONE' ? '轮空' : row.rewardName.trim(),
|
||
rewardScene: row.rewardScene,
|
||
rewardType: row.rewardType,
|
||
rewardCopies:
|
||
row.rewardScene === 'IN_ROOM' ? toNumber(row.rewardCopies, 1) : 1,
|
||
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: 1500 }"
|
||
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 === 'previewRocketUrl'">
|
||
<div class="resource-cell">
|
||
<div class="resource-cell__preview">
|
||
<img
|
||
v-if="record.previewRocketUrl"
|
||
:src="record.previewRocketUrl"
|
||
alt=""
|
||
/>
|
||
<span>{{ resourceUrlName(record.previewRocketUrl) }}</span>
|
||
</div>
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
@click="
|
||
openLevelResourcePicker(record, 'previewRocketUrl')
|
||
"
|
||
>
|
||
{{ record.previewRocketUrl ? '更换资源' : '选择资源' }}
|
||
</Button>
|
||
<Button
|
||
v-if="record.previewRocketUrl"
|
||
size="small"
|
||
@click="clearLevelResource(record, 'previewRocketUrl')"
|
||
>
|
||
清除
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'rocketIconUrl'">
|
||
<div class="resource-cell">
|
||
<div class="resource-cell__preview">
|
||
<img
|
||
v-if="record.rocketIconUrl"
|
||
:src="record.rocketIconUrl"
|
||
alt=""
|
||
/>
|
||
<span>{{ resourceUrlName(record.rocketIconUrl) }}</span>
|
||
</div>
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
@click="openLevelResourcePicker(record, 'rocketIconUrl')"
|
||
>
|
||
{{ record.rocketIconUrl ? '更换资源' : '选择资源' }}
|
||
</Button>
|
||
<Button
|
||
v-if="record.rocketIconUrl"
|
||
size="small"
|
||
@click="clearLevelResource(record, 'rocketIconUrl')"
|
||
>
|
||
清除
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'rocketAnimationUrl'">
|
||
<div class="resource-cell">
|
||
<div class="resource-cell__preview">
|
||
<img
|
||
v-if="record.rocketAnimationUrl"
|
||
:src="record.rocketAnimationUrl"
|
||
alt=""
|
||
/>
|
||
<span>{{ resourceUrlName(record.rocketAnimationUrl) }}</span>
|
||
</div>
|
||
<Space>
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
@click="
|
||
openLevelResourcePicker(record, 'rocketAnimationUrl')
|
||
"
|
||
>
|
||
{{ record.rocketAnimationUrl ? '更换资源' : '选择资源' }}
|
||
</Button>
|
||
<Button
|
||
v-if="record.rocketAnimationUrl"
|
||
size="small"
|
||
@click="clearLevelResource(record, 'rocketAnimationUrl')"
|
||
>
|
||
清除
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'shakeThresholdPercent'">
|
||
<Input
|
||
v-model:value="record.shakeThresholdPercent"
|
||
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 @click="addRewardRow('IN_ROOM', 'NONE')">添加在房轮空</Button>
|
||
<Button type="primary" :loading="rewardSaving" @click="saveRewards">
|
||
保存当前等级奖励
|
||
</Button>
|
||
</Space>
|
||
|
||
<Table
|
||
:columns="rewardColumns"
|
||
:data-source="rewardRows"
|
||
:loading="rewardLoading"
|
||
:pagination="false"
|
||
:scroll="{ x: 1770 }"
|
||
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"
|
||
@change="() => handleRewardSceneChange(record)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardType'">
|
||
<Select
|
||
v-model:value="record.rewardType"
|
||
:options="rewardTypeOptionsForRow(record)"
|
||
class="table-select"
|
||
@change="() => handleRewardTypeChange(record)"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardItemId'">
|
||
<div class="resource-cell">
|
||
<div class="resource-cell__summary">
|
||
<span v-if="isRewardResourceDisabled(record)">-</span>
|
||
<span v-else-if="record.rewardItemId">
|
||
{{ record.rewardName || '已选资源' }} / ID:
|
||
{{ record.rewardItemId }}
|
||
</span>
|
||
<span v-else>未选择</span>
|
||
</div>
|
||
<Space v-if="!isRewardResourceDisabled(record)">
|
||
<Button
|
||
size="small"
|
||
type="primary"
|
||
@click="openRewardResourcePicker(record)"
|
||
>
|
||
{{ record.rewardItemId ? '更换资源' : '选择资源' }}
|
||
</Button>
|
||
<Button
|
||
v-if="record.rewardItemId"
|
||
size="small"
|
||
@click="clearRewardResource(record)"
|
||
>
|
||
清除
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardName'">
|
||
<Input
|
||
v-model:value="record.rewardName"
|
||
:disabled="record.rewardType === 'NONE'"
|
||
class="table-input-small"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardCover'">
|
||
<div class="cover-preview">
|
||
<img
|
||
v-if="record.rewardCover"
|
||
:src="record.rewardCover"
|
||
alt=""
|
||
/>
|
||
<span>{{ record.rewardCover || '-' }}</span>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardAmount'">
|
||
<InputNumber
|
||
v-model:value="record.rewardAmount"
|
||
:disabled="record.rewardType === 'NONE'"
|
||
:min="record.rewardType === 'NONE' ? 0 : 1"
|
||
:precision="0"
|
||
class="table-number"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'expireDays'">
|
||
<InputNumber
|
||
v-model:value="record.expireDays"
|
||
:disabled="
|
||
record.rewardType === 'GOLD' || record.rewardType === 'NONE'
|
||
"
|
||
:min="0"
|
||
:precision="0"
|
||
class="table-number"
|
||
/>
|
||
</template>
|
||
<template v-else-if="column.key === 'rewardCopies'">
|
||
<InputNumber
|
||
v-model:value="record.rewardCopies"
|
||
:disabled="record.rewardScene !== 'IN_ROOM'"
|
||
:min="1"
|
||
: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>
|
||
|
||
<ResourceSelectDrawer
|
||
:default-type="resourcePickerDefaultType"
|
||
:fixed-type="resourcePickerFixedType"
|
||
:open="resourcePickerOpen"
|
||
:sys-origin="selectedSysOrigin"
|
||
:title="resourcePickerTitle"
|
||
@close="resourcePickerOpen = false"
|
||
@select="handleResourceSelect"
|
||
/>
|
||
</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;
|
||
}
|
||
|
||
.resource-cell {
|
||
display: grid;
|
||
gap: 8px;
|
||
min-width: 220px;
|
||
}
|
||
|
||
.resource-cell__preview {
|
||
align-items: center;
|
||
color: #6b7280;
|
||
display: flex;
|
||
font-size: 12px;
|
||
gap: 8px;
|
||
min-height: 36px;
|
||
}
|
||
|
||
.resource-cell__preview img {
|
||
border-radius: 4px;
|
||
flex: 0 0 auto;
|
||
height: 32px;
|
||
object-fit: cover;
|
||
width: 32px;
|
||
}
|
||
|
||
.resource-cell__preview span,
|
||
.resource-cell__summary {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
max-width: 220px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cover-preview {
|
||
display: flex;
|
||
align-items: center;
|
||
max-width: 220px;
|
||
gap: 8px;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.cover-preview img {
|
||
width: 32px;
|
||
height: 32px;
|
||
flex: 0 0 auto;
|
||
border-radius: 4px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.cover-preview span {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
</style>
|