Compare commits

...

4 Commits

Author SHA1 Message Date
zhx
f46a95df79 fix: replace rocket resource type dropdown 2026-05-15 10:57:53 +08:00
zhx
5bbb158f57 fix: use drawer resource picker for rocket config 2026-05-15 10:46:18 +08:00
zhx
20c00c14d5 砸蛋和转盘 2026-05-15 09:58:36 +08:00
zhx
2f9ae0a10e 修改 2026-05-14 19:47:41 +08:00
9 changed files with 3772 additions and 119 deletions

View File

@ -12,6 +12,8 @@ const RECHARGE_REWARD_BASE = '/go/resident-activity/recharge-reward';
const TASK_CENTER_BASE = '/go/resident-activity/task-center';
const VOICE_ROOM_RED_PACKET_BASE = '/go/resident-activity/voice-room-red-packet';
const VOICE_ROOM_ROCKET_BASE = '/go/resident-activity/voice-room-rocket';
const WHEEL_BASE = '/go/resident-activity/wheel';
const SMASH_GOLDEN_EGG_BASE = '/go/resident-activity/smash-golden-egg';
export async function getResidentInviteConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>('/resident-activity/invite/config', {
@ -100,6 +102,51 @@ export async function pageResidentVipUserStates(params: Record<string, any>) {
});
}
export async function getResidentWheelConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>(`${WHEEL_BASE}/config`, {
params: { sysOrigin },
});
}
export async function saveResidentWheelConfig(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(`${WHEEL_BASE}/config/save`, data);
}
export async function saveResidentWheelCategoryConfig(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
`${WHEEL_BASE}/config/category/save`,
data,
);
}
export async function pageResidentWheelDrawRecords(params: Record<string, any>) {
return requestClient.get<Record<string, any>>(`${WHEEL_BASE}/record/page`, {
params,
});
}
export async function getResidentSmashGoldenEggConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>(`${SMASH_GOLDEN_EGG_BASE}/config`, {
params: { sysOrigin },
});
}
export async function saveResidentSmashGoldenEggConfig(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
`${SMASH_GOLDEN_EGG_BASE}/config/save`,
data,
);
}
export async function pageResidentSmashGoldenEggDrawRecords(
params: Record<string, any>,
) {
return requestClient.get<Record<string, any>>(
`${SMASH_GOLDEN_EGG_BASE}/draw-record/page`,
{ params },
);
}
export async function getResidentRechargeRewardConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>(`${RECHARGE_REWARD_BASE}/config`, {
params: { sysOrigin },

View File

@ -62,6 +62,12 @@ const ROUTE_MENU_OVERRIDES: Record<
},
};
const LOCAL_RESIDENT_ACTIVITY_FALLBACK_ROUTES = new Set([
'ResidentWheelConfig',
'ResidentSmashGoldenEggConfig',
'ResidentVoiceRoomRocket',
]);
function normalizeRoutePath(path?: string | null) {
return String(path || '')
.trim()
@ -109,6 +115,7 @@ function buildAuthorizedMenuMatcher(menus: LegacyMenu[]) {
const aliasSet = new Set<string>();
const routerSet = new Set<string>();
const titleSet = new Set<string>();
let hasResidentActivityAccess = false;
const queue = [...menus];
while (queue.length > 0) {
@ -119,18 +126,34 @@ function buildAuthorizedMenuMatcher(menus: LegacyMenu[]) {
if (item.alias) {
aliasSet.add(String(item.alias));
}
if (item.router) {
routerSet.add(normalizeRoutePath(item.router));
const normalizedRouter = normalizeRoutePath(item.router);
if (normalizedRouter) {
routerSet.add(normalizedRouter);
}
if (item.menuType === 2 && item.menuName) {
titleSet.add(String(item.menuName).trim());
}
const menuName = String(item.menuName || '').trim();
if (
String(item.alias || '') === 'ResidentActivityManager' ||
menuName === '常驻活动' ||
normalizedRouter === 'resident-activity/manager' ||
normalizedRouter.startsWith('resident-activity/manager/')
) {
hasResidentActivityAccess = true;
}
if (Array.isArray(item.childrens) && item.childrens.length > 0) {
queue.push(...item.childrens);
}
}
return (route: RouteRecordRaw) => {
if (
hasResidentActivityAccess &&
LOCAL_RESIDENT_ACTIVITY_FALLBACK_ROUTES.has(String(route.name || ''))
) {
return true;
}
for (const alias of collectRouteAliases(route)) {
if (aliasSet.has(alias)) {
return true;

View File

@ -23,6 +23,19 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/resident-activity/vip-config.vue'),
meta: { title: 'VIP' },
},
{
name: 'ResidentWheelConfig',
path: 'wheel/config',
component: () => import('#/views/resident-activity/wheel-config.vue'),
meta: { title: '转盘' },
},
{
name: 'ResidentSmashGoldenEggConfig',
path: 'smash-golden-egg/config',
component: () =>
import('#/views/resident-activity/smash-golden-egg.vue'),
meta: { title: '砸蛋' },
},
{
name: 'ResidentRechargeRewardConfig',
path: 'recharge-reward/config',

View File

@ -1,14 +1,6 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { listEmojiGroupsBySysOrigin } from '#/api/legacy/app-system';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
listSysOriginTypeList,
} from '#/api/legacy/props';
import {
Drawer,
Empty,
@ -18,8 +10,32 @@ import {
Spin,
} from 'antdv-next';
import { listEmojiGroupsBySysOrigin } from '#/api/legacy/app-system';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
listSysOriginTypeList,
} from '#/api/legacy/props';
defineOptions({ name: 'OperatePropsSourceSelectDrawer' });
const props = withDefaults(
defineProps<{
defaultType?: string;
open: boolean;
sysOrigin: string;
}>(),
{
defaultType: 'AVATAR_FRAME',
},
);
const emit = defineEmits<{
close: [];
select: [item: SourceItem];
}>();
type SourceTypeOption = {
label: string;
value: string;
@ -53,22 +69,6 @@ const typeSelectOptions = TYPE_OPTIONS.map((item) => ({
value: item.value as any,
}));
const props = withDefaults(
defineProps<{
defaultType?: string;
open: boolean;
sysOrigin: string;
}>(),
{
defaultType: 'AVATAR_FRAME',
},
);
const emit = defineEmits<{
close: [];
select: [item: SourceItem];
}>();
const loading = ref(false);
const keyword = ref('');
const query = reactive({
@ -103,6 +103,89 @@ const currentList = computed(() => {
);
});
function firstPresent(record: Record<string, any>, keys: string[]) {
for (const key of keys) {
const value = record?.[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
return value;
}
}
return '';
}
function normalizeSourceItem(
item: Record<string, any>,
type: string,
options: {
coverKeys?: string[];
idKeys?: string[];
nameKeys?: string[];
sourceUrlKeys?: string[];
} = {},
): SourceItem {
const id = firstPresent(
item,
options.idKeys || [
'id',
'resourceId',
'sourceId',
'propsId',
'propsSourceId',
'badgeConfigId',
'giftId',
'groupId',
],
);
return {
amount: firstPresent(item, ['amount', 'giftCandy', 'price', 'goldAmount']),
cover: String(
firstPresent(
item,
options.coverKeys || [
'cover',
'coverUrl',
'resourceCover',
'resourceCoverUrl',
'giftPhoto',
'selectUrl',
'icon',
'smallIcon',
],
),
),
id: id as number | string,
name: String(
firstPresent(
item,
options.nameKeys || [
'name',
'resourceName',
'sourceName',
'giftName',
'badgeName',
'groupName',
'title',
'remark',
],
) || (id ? `资源 ${id}` : ''),
),
sourceUrl: String(
firstPresent(
item,
options.sourceUrlKeys || [
'sourceUrl',
'resourceUrl',
'url',
'giftSourceUrl',
'animationUrl',
'playUrl',
],
),
),
type,
};
}
watch(
() => props.open,
(open) => {
@ -150,56 +233,53 @@ async function loadSource(type: string) {
store.loading = true;
loading.value = true;
try {
if (type === 'GIFT') {
const result = await listGiftBySysOrigin(props.sysOrigin);
store.list = (result || []).map((item) => ({
amount: item.giftCandy,
cover: item.giftPhoto,
id: item.id,
name: item.giftName,
sourceUrl: item.giftSourceUrl,
type,
}));
} else if (type === 'EMOJI') {
const result = await listEmojiGroupsBySysOrigin(props.sysOrigin);
store.list = (result || []).map((item) => ({
amount: item.amount,
cover: item.cover,
id: item.id || '',
name: item.groupName || '',
sourceUrl: '',
type,
}));
} else if (type === 'BADGE' || type === 'ROOM_BADGE') {
const badgeType = type === 'BADGE' ? 'ACTIVITY' : 'ROOM_ACHIEVEMENT';
const result = await listBadgePictureBySysOrigin(props.sysOrigin, badgeType);
store.list = (result || []).map((item) => ({
cover: item.selectUrl,
id: item.badgeConfigId || '',
name: item.badgeName || '',
sourceUrl: item.animationUrl,
type,
}));
} else if (type === 'NOBLE_VIP') {
const result = await listNotFamilyBySysOriginType(props.sysOrigin, type);
store.list = (result || []).map((item) => ({
amount: item.amount,
cover: item.cover,
id: item.id || '',
name: item.name || '',
sourceUrl: item.sourceUrl,
type,
}));
} else {
const result = await listSysOriginTypeList(props.sysOrigin, type);
store.list = (result || []).map((item) => ({
amount: item.amount,
cover: item.cover,
id: item.id || '',
name: item.name || '',
sourceUrl: item.sourceUrl,
type,
}));
switch (type) {
case 'BADGE':
case 'ROOM_BADGE': {
const badgeType = type === 'BADGE' ? 'ACTIVITY' : 'ROOM_ACHIEVEMENT';
const result = await listBadgePictureBySysOrigin(props.sysOrigin, badgeType);
store.list = (result || []).map((item) =>
normalizeSourceItem(item, type, {
coverKeys: ['selectUrl', 'cover', 'coverUrl'],
idKeys: ['badgeConfigId', 'id', 'badgeId'],
nameKeys: ['badgeName', 'name'],
sourceUrlKeys: ['animationUrl', 'sourceUrl', 'resourceUrl'],
}),
);
break;
}
case 'EMOJI': {
const result = await listEmojiGroupsBySysOrigin(props.sysOrigin);
store.list = (result || []).map((item) =>
normalizeSourceItem(item, type, {
idKeys: ['id', 'groupId'],
nameKeys: ['groupName', 'name'],
sourceUrlKeys: [],
}),
);
break;
}
case 'GIFT': {
const result = await listGiftBySysOrigin(props.sysOrigin);
store.list = (result || []).map((item) =>
normalizeSourceItem(item, type, {
coverKeys: ['giftPhoto', 'cover', 'coverUrl'],
idKeys: ['id', 'giftId'],
nameKeys: ['giftName', 'name'],
sourceUrlKeys: ['giftSourceUrl', 'sourceUrl', 'resourceUrl'],
}),
);
break;
}
case 'NOBLE_VIP': {
const result = await listNotFamilyBySysOriginType(props.sysOrigin, type);
store.list = (result || []).map((item) => normalizeSourceItem(item, type));
break;
}
default: {
const result = await listSysOriginTypeList(props.sysOrigin, type);
store.list = (result || []).map((item) => normalizeSourceItem(item, type));
}
}
store.loaded = true;
} finally {
@ -258,7 +338,7 @@ function handleSelect(item: SourceItem) {
:src="item.cover || 'https://dummyimage.com/88x88/e2e8f0/64748b&text=+'"
alt=""
class="source-cover"
>
/>
</div>
<div class="source-name">{{ item.name || '-' }}</div>
<div class="source-meta">ID {{ item.id }}</div>

View File

@ -0,0 +1,452 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
pagePropsSource,
} from '#/api/legacy/props';
import {
Button,
Drawer,
Empty,
Input,
Pagination,
Space,
Spin,
} from 'antdv-next';
defineOptions({ name: 'ResidentActivityResourceSelectDrawer' });
type ResourceItem = {
amount?: number | string;
cover: string;
id: number | string;
name: string;
sourceUrl: string;
type: string;
};
const props = withDefaults(
defineProps<{
defaultType?: string;
fixedType?: boolean;
open: boolean;
sysOrigin: string;
title?: string;
}>(),
{
defaultType: 'ALL_PROPS',
fixedType: false,
title: '选择资源',
},
);
const emit = defineEmits<{
close: [];
select: [item: ResourceItem];
}>();
const typeOptions = [
{ label: '全部资源', value: 'ALL_PROPS' },
{ label: '自定义', value: 'CUSTOMIZE' },
{ label: '头像框', value: 'AVATAR_FRAME' },
{ label: '座驾', value: 'RIDE' },
{ label: '聊天气泡', value: 'CHAT_BUBBLE' },
{ label: '贵族 VIP', value: 'NOBLE_VIP' },
{ label: '主题背景', value: 'THEME' },
{ label: '飘窗', value: 'FLOAT_PICTURE' },
{ label: '礼物', value: 'GIFT' },
{ label: '徽章', value: 'BADGE' },
].map((item) => ({ ...item, value: item.value as any }));
const listOnlyTypes = new Set(['BADGE', 'GIFT', 'NOBLE_VIP']);
const loading = ref(false);
const list = ref<ResourceItem[]>([]);
const total = ref(0);
const query = reactive({
cursor: 1,
keyword: '',
limit: 24,
type: 'ALL_PROPS',
});
const localRows = ref<ResourceItem[]>([]);
const filteredLocalRows = computed(() => {
const keyword = query.keyword.trim().toLowerCase();
if (!keyword) {
return localRows.value;
}
return localRows.value.filter(
(item) =>
String(item.id).toLowerCase().includes(keyword) ||
item.name.toLowerCase().includes(keyword),
);
});
function firstPresent(record: Record<string, any>, keys: string[]) {
for (const key of keys) {
const value = record?.[key];
if (value !== undefined && value !== null && String(value).trim() !== '') {
return value;
}
}
return '';
}
function normalizeResource(
item: Record<string, any>,
type: string,
options: {
coverKeys?: string[];
idKeys?: string[];
nameKeys?: string[];
sourceUrlKeys?: string[];
} = {},
): ResourceItem {
const id = firstPresent(
item,
options.idKeys || ['id', 'resourceId', 'sourceId', 'propsId'],
);
return {
amount: firstPresent(item, ['amount', 'giftCandy', 'price']),
cover: String(
firstPresent(
item,
options.coverKeys || [
'cover',
'coverUrl',
'resourceCover',
'resourceCoverUrl',
'giftPhoto',
'selectUrl',
'icon',
],
),
),
id: id as number | string,
name: String(
firstPresent(
item,
options.nameKeys || [
'name',
'resourceName',
'sourceName',
'giftName',
'badgeName',
'title',
'remark',
],
) || (id ? `资源 ${id}` : ''),
),
sourceUrl: String(
firstPresent(
item,
options.sourceUrlKeys || [
'sourceUrl',
'resourceUrl',
'url',
'giftSourceUrl',
'animationUrl',
],
),
),
type,
};
}
function applyLocalPage() {
const rows = filteredLocalRows.value;
total.value = rows.length;
const start = (query.cursor - 1) * query.limit;
list.value = rows.slice(start, start + query.limit);
}
async function loadListType(type: string) {
if (type === 'GIFT') {
const result = await listGiftBySysOrigin(props.sysOrigin);
localRows.value = (result || []).map((item) =>
normalizeResource(item, type, {
coverKeys: ['giftPhoto', 'cover'],
idKeys: ['id', 'giftId'],
nameKeys: ['giftName', 'name'],
sourceUrlKeys: ['giftSourceUrl', 'sourceUrl'],
}),
);
applyLocalPage();
return;
}
if (type === 'BADGE') {
const result = await listBadgePictureBySysOrigin(props.sysOrigin, 'ACTIVITY');
localRows.value = (result || []).map((item) =>
normalizeResource(item, type, {
coverKeys: ['selectUrl', 'cover'],
idKeys: ['badgeConfigId', 'id'],
nameKeys: ['badgeName', 'name'],
sourceUrlKeys: ['animationUrl', 'sourceUrl'],
}),
);
applyLocalPage();
return;
}
if (type === 'NOBLE_VIP') {
const result = await listNotFamilyBySysOriginType(props.sysOrigin, type);
localRows.value = (result || []).map((item) =>
normalizeResource(item, type),
);
applyLocalPage();
}
}
async function loadData(reset = false) {
if (!props.open || !props.sysOrigin) {
return;
}
if (reset) {
query.cursor = 1;
}
loading.value = true;
try {
if (listOnlyTypes.has(query.type)) {
await loadListType(query.type);
return;
}
const keyword = query.keyword.trim();
const params: Record<string, any> = {
cursor: query.cursor,
del: false,
limit: query.limit,
sysOrigin: props.sysOrigin,
};
if (query.type && query.type !== 'ALL_PROPS') {
params.type = query.type;
}
if (keyword) {
if (/^\d+$/.test(keyword)) {
params.id = keyword;
} else {
params.name = keyword;
}
}
const result = await pagePropsSource(params);
list.value = (result.records || []).map((item) =>
normalizeResource(item, String(item.type || query.type || '')),
);
total.value = result.total || 0;
} finally {
loading.value = false;
}
}
function handleTypeChange(value: string) {
query.type = value || 'ALL_PROPS';
query.keyword = '';
localRows.value = [];
void loadData(true);
}
function handleSearch() {
void loadData(true);
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
if (listOnlyTypes.has(query.type)) {
applyLocalPage();
return;
}
void loadData();
}
function handleSelect(item: ResourceItem) {
emit('select', item);
}
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
query.type = props.defaultType || 'ALL_PROPS';
query.keyword = '';
query.cursor = 1;
localRows.value = [];
void loadData(true);
},
{ immediate: true },
);
watch(
() => props.defaultType,
(value) => {
if (!props.open || !value) {
return;
}
query.type = value;
query.keyword = '';
localRows.value = [];
void loadData(true);
},
);
watch(
() => props.sysOrigin,
() => {
localRows.value = [];
if (props.open) {
void loadData(true);
}
},
);
</script>
<template>
<Drawer
:open="open"
destroy-on-close
:title="title"
width="920"
@close="emit('close')"
>
<div v-if="!fixedType" class="type-filter">
<Button
v-for="item in typeOptions"
:key="item.value"
size="small"
:type="query.type === item.value ? 'primary' : 'default'"
@click="handleTypeChange(item.value)"
>
{{ item.label }}
</Button>
</div>
<Space class="toolbar" wrap>
<Input
v-model:value="query.keyword"
allow-clear
placeholder="资源名称 / ID"
style="width: 260px"
@press-enter="handleSearch"
/>
<Button :loading="loading" type="primary" @click="handleSearch">
搜索
</Button>
</Space>
<Spin :spinning="loading">
<div v-if="list.length > 0" class="resource-grid">
<button
v-for="item in list"
:key="`${item.type}-${item.id}`"
class="resource-card"
type="button"
@click="handleSelect(item)"
>
<div class="resource-cover-wrap">
<img
:src="item.cover || 'https://dummyimage.com/88x88/e2e8f0/64748b&text=+'"
alt=""
class="resource-cover"
/>
</div>
<div class="resource-name">{{ item.name || '-' }}</div>
<div class="resource-meta">ID {{ item.id }}</div>
<div class="resource-type">{{ item.type || '-' }}</div>
</button>
</div>
<Empty v-else description="暂无可选资源" />
</Spin>
<div class="pager">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Drawer>
</template>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.type-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.resource-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
}
.resource-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
padding: 12px;
text-align: center;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.resource-card:hover {
border-color: #2563eb;
box-shadow: 0 12px 24px rgb(37 99 235 / 12%);
transform: translateY(-2px);
}
.resource-cover-wrap {
align-items: center;
display: flex;
height: 88px;
justify-content: center;
margin-bottom: 10px;
}
.resource-cover {
border-radius: 8px;
height: 88px;
object-fit: cover;
width: 88px;
}
.resource-name {
font-weight: 600;
line-height: 1.4;
min-height: 40px;
word-break: break-all;
}
.resource-meta,
.resource-type {
color: #64748b;
font-size: 12px;
margin-top: 6px;
word-break: break-all;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,8 @@ import {
} 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';
@ -41,7 +43,6 @@ type LevelRow = {
id?: number | string;
level: number;
needEnergy: number;
progressBarUrl: string;
rocketAnimationUrl: string;
rocketIconUrl: string;
shakeThresholdPercent: string;
@ -64,6 +65,16 @@ type RewardRow = {
weight: number;
};
type ResourceOption = {
amount?: number | string;
cover: string;
id: number | string;
name: string;
remark?: string;
sourceUrl: string;
type: string;
};
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
@ -99,6 +110,23 @@ const configForm = reactive({
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: 'rocketAnimationUrl' | 'rocketIconUrl';
kind: 'level';
row: Record<string, any>;
}
| {
kind: 'reward';
row: Record<string, any>;
}
| null
>(null);
const sceneOptions = [
{ label: '贡献第一', value: 'TOP1' },
{ label: '点火人', value: 'IGNITE' },
@ -113,6 +141,7 @@ const rewardTypeOptions = [
{ label: 'VIP', value: 'VIP_CARD' },
{ label: '礼物', value: 'GIFT' },
{ label: '徽章', value: 'BADGE' },
{ label: '轮空', value: 'NONE' },
];
const levelOptions = computed(() =>
@ -122,6 +151,12 @@ const levelOptions = computed(() =>
})),
);
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: '未配置' };
@ -134,10 +169,14 @@ const statusMeta = computed(() => {
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: '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 },
];
@ -145,9 +184,9 @@ const levelColumns = [
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: 'rewardItemId', key: 'rewardItemId', title: '资源', width: 220 },
{ dataIndex: 'rewardName', key: 'rewardName', title: '奖励名称', width: 180 },
{ dataIndex: 'rewardCover', key: 'rewardCover', title: '封面URL', width: 220 },
{ dataIndex: 'rewardCover', key: 'rewardCover', title: '封面', width: 220 },
{ dataIndex: 'rewardAmount', key: 'rewardAmount', title: '数量/金币/天数', width: 150 },
{ dataIndex: 'expireDays', key: 'expireDays', title: '有效天数', width: 130 },
{ dataIndex: 'weight', key: 'weight', title: '权重', width: 110 },
@ -161,7 +200,6 @@ function createDefaultLevel(level: number): LevelRow {
enabled: true,
level,
needEnergy: level * 100,
progressBarUrl: '',
rocketAnimationUrl: '',
rocketIconUrl: '',
shakeThresholdPercent: '95.0000',
@ -246,7 +284,6 @@ function normalizeLevelRows(rows: Array<Record<string, any>> | undefined) {
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'),
@ -273,6 +310,115 @@ function normalizeRewardRows(rows: Array<Record<string, any>> | undefined) {
}));
}
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: 'rocketAnimationUrl' | 'rocketIconUrl',
) {
resourcePickerTarget.value = {
field,
kind: 'level',
row,
};
resourcePickerTitle.value =
field === 'rocketIconUrl' ? '选择火箭图标资源' : '选择发射动画资源';
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: 'rocketAnimationUrl' | 'rocketIconUrl',
) {
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);
}
}
function handleResourceSelect(item: ResourceOption) {
const target = resourcePickerTarget.value;
if (!target) {
return;
}
if (target.kind === 'level') {
const value =
target.field === 'rocketIconUrl'
? String(item.cover || item.sourceUrl || '')
: String(item.sourceUrl || item.cover || '');
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;
@ -361,7 +507,6 @@ async function saveLevels() {
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,
@ -376,18 +521,19 @@ async function saveLevels() {
}
}
function addRewardRow(scene = 'TOP1') {
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: 1,
rewardAmount: isNone ? 0 : 1,
rewardCover: '',
rewardItemId: null,
rewardName: scene === 'TOP1' ? '金币' : '',
rewardName: isNone ? '轮空' : rewardType === 'GOLD' ? '金币' : '',
rewardScene: scene,
rewardType: 'GOLD',
rewardType,
sort: rewardRows.value.length + 1,
weight: 1,
});
@ -398,12 +544,30 @@ function removeRewardRow(index: number) {
}
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 = '';
}
}
@ -417,12 +581,20 @@ function validateRewards() {
message.warning('请选择奖励类型');
return false;
}
if (row.rewardType === 'NONE' && row.rewardScene !== 'IN_ROOM') {
message.warning('轮空只能配置在房用户奖励');
return false;
}
if (!String(row.rewardName || '').trim()) {
message.warning('请输入奖励名称');
return false;
}
if (row.rewardType !== 'GOLD' && !String(row.rewardItemId || '').trim()) {
message.warning('非金币奖励必须填写资源ID');
if (
row.rewardType !== 'GOLD' &&
row.rewardType !== 'NONE' &&
!String(row.rewardItemId || '').trim()
) {
message.warning('非金币奖励必须选择资源');
return false;
}
}
@ -439,14 +611,20 @@ async function saveRewards() {
level: rewardLevel.value,
rewards: rewardRows.value.map((row) => ({
enabled: row.enabled,
expireDays: toOptionalNumber(row.expireDays),
expireDays:
row.rewardType === 'GOLD' || row.rewardType === 'NONE'
? undefined
: toOptionalNumber(row.expireDays),
id: row.id,
level: rewardLevel.value,
rewardAmount: toNumber(row.rewardAmount, 1),
rewardCover: row.rewardCover,
rewardAmount:
row.rewardType === 'NONE' ? 0 : toNumber(row.rewardAmount, 1),
rewardCover: row.rewardType === 'NONE' ? '' : row.rewardCover,
rewardItemId:
row.rewardType === 'GOLD' ? undefined : toOptionalString(row.rewardItemId),
rewardName: row.rewardName.trim(),
row.rewardType === 'GOLD' || row.rewardType === 'NONE'
? undefined
: toOptionalString(row.rewardItemId),
rewardName: row.rewardType === 'NONE' ? '轮空' : row.rewardName.trim(),
rewardScene: row.rewardScene,
rewardType: row.rewardType,
sort: toNumber(row.sort, 1),
@ -605,7 +783,7 @@ watch(rewardLevel, () => {
:data-source="levelRows"
:loading="levelLoading"
:pagination="false"
:scroll="{ x: 1420 }"
:scroll="{ x: 1240 }"
row-key="level"
>
<template #bodyCell="{ column, record }">
@ -620,24 +798,70 @@ watch(rewardLevel, () => {
class="table-number"
/>
</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 === '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>
@ -676,6 +900,7 @@ watch(rewardLevel, () => {
<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>
@ -695,33 +920,67 @@ watch(rewardLevel, () => {
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="rewardTypeOptions"
: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.rewardItemId"
:disabled="record.rewardType === 'GOLD'"
v-model:value="record.rewardName"
:disabled="record.rewardType === 'NONE'"
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" />
<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"
:min="1"
:disabled="record.rewardType === 'NONE'"
:min="record.rewardType === 'NONE' ? 0 : 1"
:precision="0"
class="table-number"
/>
@ -729,7 +988,9 @@ watch(rewardLevel, () => {
<template v-else-if="column.key === 'expireDays'">
<InputNumber
v-model:value="record.expireDays"
:disabled="record.rewardType === 'GOLD'"
:disabled="
record.rewardType === 'GOLD' || record.rewardType === 'NONE'
"
:min="0"
:precision="0"
class="table-number"
@ -777,6 +1038,16 @@ watch(rewardLevel, () => {
</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>
@ -843,4 +1114,60 @@ watch(rewardLevel, () => {
.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>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
4934