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 TASK_CENTER_BASE = '/go/resident-activity/task-center';
const VOICE_ROOM_RED_PACKET_BASE = '/go/resident-activity/voice-room-red-packet'; 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 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) { export async function getResidentInviteConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>('/resident-activity/invite/config', { 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) { export async function getResidentRechargeRewardConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>(`${RECHARGE_REWARD_BASE}/config`, { return requestClient.get<Record<string, any>>(`${RECHARGE_REWARD_BASE}/config`, {
params: { sysOrigin }, 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) { function normalizeRoutePath(path?: string | null) {
return String(path || '') return String(path || '')
.trim() .trim()
@ -109,6 +115,7 @@ function buildAuthorizedMenuMatcher(menus: LegacyMenu[]) {
const aliasSet = new Set<string>(); const aliasSet = new Set<string>();
const routerSet = new Set<string>(); const routerSet = new Set<string>();
const titleSet = new Set<string>(); const titleSet = new Set<string>();
let hasResidentActivityAccess = false;
const queue = [...menus]; const queue = [...menus];
while (queue.length > 0) { while (queue.length > 0) {
@ -119,18 +126,34 @@ function buildAuthorizedMenuMatcher(menus: LegacyMenu[]) {
if (item.alias) { if (item.alias) {
aliasSet.add(String(item.alias)); aliasSet.add(String(item.alias));
} }
if (item.router) { const normalizedRouter = normalizeRoutePath(item.router);
routerSet.add(normalizeRoutePath(item.router)); if (normalizedRouter) {
routerSet.add(normalizedRouter);
} }
if (item.menuType === 2 && item.menuName) { if (item.menuType === 2 && item.menuName) {
titleSet.add(String(item.menuName).trim()); 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) { if (Array.isArray(item.childrens) && item.childrens.length > 0) {
queue.push(...item.childrens); queue.push(...item.childrens);
} }
} }
return (route: RouteRecordRaw) => { return (route: RouteRecordRaw) => {
if (
hasResidentActivityAccess &&
LOCAL_RESIDENT_ACTIVITY_FALLBACK_ROUTES.has(String(route.name || ''))
) {
return true;
}
for (const alias of collectRouteAliases(route)) { for (const alias of collectRouteAliases(route)) {
if (aliasSet.has(alias)) { if (aliasSet.has(alias)) {
return true; return true;

View File

@ -23,6 +23,19 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/resident-activity/vip-config.vue'), component: () => import('#/views/resident-activity/vip-config.vue'),
meta: { title: 'VIP' }, 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', name: 'ResidentRechargeRewardConfig',
path: 'recharge-reward/config', path: 'recharge-reward/config',

View File

@ -1,14 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'; 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 { import {
Drawer, Drawer,
Empty, Empty,
@ -18,8 +10,32 @@ import {
Spin, Spin,
} from 'antdv-next'; } 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' }); 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 = { type SourceTypeOption = {
label: string; label: string;
value: string; value: string;
@ -53,22 +69,6 @@ const typeSelectOptions = TYPE_OPTIONS.map((item) => ({
value: item.value as any, 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 loading = ref(false);
const keyword = ref(''); const keyword = ref('');
const query = reactive({ 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( watch(
() => props.open, () => props.open,
(open) => { (open) => {
@ -150,56 +233,53 @@ async function loadSource(type: string) {
store.loading = true; store.loading = true;
loading.value = true; loading.value = true;
try { try {
if (type === 'GIFT') { switch (type) {
const result = await listGiftBySysOrigin(props.sysOrigin); case 'BADGE':
store.list = (result || []).map((item) => ({ case 'ROOM_BADGE': {
amount: item.giftCandy, const badgeType = type === 'BADGE' ? 'ACTIVITY' : 'ROOM_ACHIEVEMENT';
cover: item.giftPhoto, const result = await listBadgePictureBySysOrigin(props.sysOrigin, badgeType);
id: item.id, store.list = (result || []).map((item) =>
name: item.giftName, normalizeSourceItem(item, type, {
sourceUrl: item.giftSourceUrl, coverKeys: ['selectUrl', 'cover', 'coverUrl'],
type, idKeys: ['badgeConfigId', 'id', 'badgeId'],
})); nameKeys: ['badgeName', 'name'],
} else if (type === 'EMOJI') { sourceUrlKeys: ['animationUrl', 'sourceUrl', 'resourceUrl'],
const result = await listEmojiGroupsBySysOrigin(props.sysOrigin); }),
store.list = (result || []).map((item) => ({ );
amount: item.amount, break;
cover: item.cover, }
id: item.id || '', case 'EMOJI': {
name: item.groupName || '', const result = await listEmojiGroupsBySysOrigin(props.sysOrigin);
sourceUrl: '', store.list = (result || []).map((item) =>
type, normalizeSourceItem(item, type, {
})); idKeys: ['id', 'groupId'],
} else if (type === 'BADGE' || type === 'ROOM_BADGE') { nameKeys: ['groupName', 'name'],
const badgeType = type === 'BADGE' ? 'ACTIVITY' : 'ROOM_ACHIEVEMENT'; sourceUrlKeys: [],
const result = await listBadgePictureBySysOrigin(props.sysOrigin, badgeType); }),
store.list = (result || []).map((item) => ({ );
cover: item.selectUrl, break;
id: item.badgeConfigId || '', }
name: item.badgeName || '', case 'GIFT': {
sourceUrl: item.animationUrl, const result = await listGiftBySysOrigin(props.sysOrigin);
type, store.list = (result || []).map((item) =>
})); normalizeSourceItem(item, type, {
} else if (type === 'NOBLE_VIP') { coverKeys: ['giftPhoto', 'cover', 'coverUrl'],
const result = await listNotFamilyBySysOriginType(props.sysOrigin, type); idKeys: ['id', 'giftId'],
store.list = (result || []).map((item) => ({ nameKeys: ['giftName', 'name'],
amount: item.amount, sourceUrlKeys: ['giftSourceUrl', 'sourceUrl', 'resourceUrl'],
cover: item.cover, }),
id: item.id || '', );
name: item.name || '', break;
sourceUrl: item.sourceUrl, }
type, case 'NOBLE_VIP': {
})); const result = await listNotFamilyBySysOriginType(props.sysOrigin, type);
} else { store.list = (result || []).map((item) => normalizeSourceItem(item, type));
const result = await listSysOriginTypeList(props.sysOrigin, type); break;
store.list = (result || []).map((item) => ({ }
amount: item.amount, default: {
cover: item.cover, const result = await listSysOriginTypeList(props.sysOrigin, type);
id: item.id || '', store.list = (result || []).map((item) => normalizeSourceItem(item, type));
name: item.name || '', }
sourceUrl: item.sourceUrl,
type,
}));
} }
store.loaded = true; store.loaded = true;
} finally { } finally {
@ -258,7 +338,7 @@ function handleSelect(item: SourceItem) {
:src="item.cover || 'https://dummyimage.com/88x88/e2e8f0/64748b&text=+'" :src="item.cover || 'https://dummyimage.com/88x88/e2e8f0/64748b&text=+'"
alt="" alt=""
class="source-cover" class="source-cover"
> />
</div> </div>
<div class="source-name">{{ item.name || '-' }}</div> <div class="source-name">{{ item.name || '-' }}</div>
<div class="source-meta">ID {{ item.id }}</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'; } from '#/api/legacy/resident-activity';
import { getAllowedSysOrigins } from '#/views/system/shared'; import { getAllowedSysOrigins } from '#/views/system/shared';
import ResourceSelectDrawer from './components/resource-select-drawer.vue';
defineOptions({ name: 'ResidentVoiceRoomRocketPage' }); defineOptions({ name: 'ResidentVoiceRoomRocketPage' });
const RIYADH_TIMEZONE = 'Asia/Riyadh'; const RIYADH_TIMEZONE = 'Asia/Riyadh';
@ -41,7 +43,6 @@ type LevelRow = {
id?: number | string; id?: number | string;
level: number; level: number;
needEnergy: number; needEnergy: number;
progressBarUrl: string;
rocketAnimationUrl: string; rocketAnimationUrl: string;
rocketIconUrl: string; rocketIconUrl: string;
shakeThresholdPercent: string; shakeThresholdPercent: string;
@ -64,6 +65,16 @@ type RewardRow = {
weight: number; weight: number;
}; };
type ResourceOption = {
amount?: number | string;
cover: string;
id: number | string;
name: string;
remark?: string;
sourceUrl: string;
type: string;
};
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const sysOriginOptions = computed(() => { const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []); const options = getAllowedSysOrigins(accessStore.accessCodes || []);
@ -99,6 +110,23 @@ const configForm = reactive({
const levelRows = ref<LevelRow[]>([]); const levelRows = ref<LevelRow[]>([]);
const rewardRows = ref<RewardRow[]>([]); 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 = [ const sceneOptions = [
{ label: '贡献第一', value: 'TOP1' }, { label: '贡献第一', value: 'TOP1' },
{ label: '点火人', value: 'IGNITE' }, { label: '点火人', value: 'IGNITE' },
@ -113,6 +141,7 @@ const rewardTypeOptions = [
{ label: 'VIP', value: 'VIP_CARD' }, { label: 'VIP', value: 'VIP_CARD' },
{ label: '礼物', value: 'GIFT' }, { label: '礼物', value: 'GIFT' },
{ label: '徽章', value: 'BADGE' }, { label: '徽章', value: 'BADGE' },
{ label: '轮空', value: 'NONE' },
]; ];
const levelOptions = computed(() => 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(() => { const statusMeta = computed(() => {
if (!configForm.configured) { if (!configForm.configured) {
return { color: 'warning', text: '未配置' }; return { color: 'warning', text: '未配置' };
@ -134,10 +169,14 @@ const statusMeta = computed(() => {
const levelColumns = [ const levelColumns = [
{ dataIndex: 'level', key: 'level', title: '等级', width: 90 }, { dataIndex: 'level', key: 'level', title: '等级', width: 90 },
{ dataIndex: 'needEnergy', key: 'needEnergy', title: '所需能量', width: 160 }, { dataIndex: 'needEnergy', key: 'needEnergy', title: '所需能量', width: 160 },
{ dataIndex: 'shakeThresholdPercent', key: 'shakeThresholdPercent', title: '晃动阈值(%)', width: 160 },
{ dataIndex: 'rocketIconUrl', key: 'rocketIconUrl', title: '火箭图标', width: 260 }, { 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: 'enabled', key: 'enabled', title: '启用', width: 90 },
{ dataIndex: 'sort', key: 'sort', title: '排序', width: 100 }, { dataIndex: 'sort', key: 'sort', title: '排序', width: 100 },
]; ];
@ -145,9 +184,9 @@ const levelColumns = [
const rewardColumns = [ const rewardColumns = [
{ dataIndex: 'rewardScene', key: 'rewardScene', title: '奖励场景', width: 150 }, { dataIndex: 'rewardScene', key: 'rewardScene', title: '奖励场景', width: 150 },
{ dataIndex: 'rewardType', key: 'rewardType', 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: '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: 'rewardAmount', key: 'rewardAmount', title: '数量/金币/天数', width: 150 },
{ dataIndex: 'expireDays', key: 'expireDays', title: '有效天数', width: 130 }, { dataIndex: 'expireDays', key: 'expireDays', title: '有效天数', width: 130 },
{ dataIndex: 'weight', key: 'weight', title: '权重', width: 110 }, { dataIndex: 'weight', key: 'weight', title: '权重', width: 110 },
@ -161,7 +200,6 @@ function createDefaultLevel(level: number): LevelRow {
enabled: true, enabled: true,
level, level,
needEnergy: level * 100, needEnergy: level * 100,
progressBarUrl: '',
rocketAnimationUrl: '', rocketAnimationUrl: '',
rocketIconUrl: '', rocketIconUrl: '',
shakeThresholdPercent: '95.0000', shakeThresholdPercent: '95.0000',
@ -246,7 +284,6 @@ function normalizeLevelRows(rows: Array<Record<string, any>> | undefined) {
enabled: row.enabled === undefined ? true : Boolean(row.enabled), enabled: row.enabled === undefined ? true : Boolean(row.enabled),
id: row.id, id: row.id,
needEnergy: toNumber(row.needEnergy, level * 100), needEnergy: toNumber(row.needEnergy, level * 100),
progressBarUrl: String(row.progressBarUrl || ''),
rocketAnimationUrl: String(row.rocketAnimationUrl || ''), rocketAnimationUrl: String(row.rocketAnimationUrl || ''),
rocketIconUrl: String(row.rocketIconUrl || ''), rocketIconUrl: String(row.rocketIconUrl || ''),
shakeThresholdPercent: String(row.shakeThresholdPercent || '95.0000'), 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() { async function loadConfig() {
if (!selectedSysOrigin.value) { if (!selectedSysOrigin.value) {
return; return;
@ -361,7 +507,6 @@ async function saveLevels() {
id: row.id, id: row.id,
level: row.level, level: row.level,
needEnergy: toNumber(row.needEnergy, row.level * 100), needEnergy: toNumber(row.needEnergy, row.level * 100),
progressBarUrl: row.progressBarUrl,
rocketAnimationUrl: row.rocketAnimationUrl, rocketAnimationUrl: row.rocketAnimationUrl,
rocketIconUrl: row.rocketIconUrl, rocketIconUrl: row.rocketIconUrl,
shakeThresholdPercent: row.shakeThresholdPercent, 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({ rewardRows.value.push({
enabled: true, enabled: true,
expireDays: null, expireDays: null,
level: rewardLevel.value, level: rewardLevel.value,
localKey: `new-${Date.now()}-${rewardRows.value.length}`, localKey: `new-${Date.now()}-${rewardRows.value.length}`,
rewardAmount: 1, rewardAmount: isNone ? 0 : 1,
rewardCover: '', rewardCover: '',
rewardItemId: null, rewardItemId: null,
rewardName: scene === 'TOP1' ? '金币' : '', rewardName: isNone ? '轮空' : rewardType === 'GOLD' ? '金币' : '',
rewardScene: scene, rewardScene: scene,
rewardType: 'GOLD', rewardType,
sort: rewardRows.value.length + 1, sort: rewardRows.value.length + 1,
weight: 1, weight: 1,
}); });
@ -398,12 +544,30 @@ function removeRewardRow(index: number) {
} }
function handleRewardTypeChange(row: Record<string, any>) { 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') { if (row.rewardType === 'GOLD') {
row.rewardItemId = null; row.rewardItemId = null;
row.rewardCover = '';
row.expireDays = null; row.expireDays = null;
if (!row.rewardName) { if (!row.rewardName) {
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('请选择奖励类型'); message.warning('请选择奖励类型');
return false; return false;
} }
if (row.rewardType === 'NONE' && row.rewardScene !== 'IN_ROOM') {
message.warning('轮空只能配置在房用户奖励');
return false;
}
if (!String(row.rewardName || '').trim()) { if (!String(row.rewardName || '').trim()) {
message.warning('请输入奖励名称'); message.warning('请输入奖励名称');
return false; return false;
} }
if (row.rewardType !== 'GOLD' && !String(row.rewardItemId || '').trim()) { if (
message.warning('非金币奖励必须填写资源ID'); row.rewardType !== 'GOLD' &&
row.rewardType !== 'NONE' &&
!String(row.rewardItemId || '').trim()
) {
message.warning('非金币奖励必须选择资源');
return false; return false;
} }
} }
@ -439,14 +611,20 @@ async function saveRewards() {
level: rewardLevel.value, level: rewardLevel.value,
rewards: rewardRows.value.map((row) => ({ rewards: rewardRows.value.map((row) => ({
enabled: row.enabled, enabled: row.enabled,
expireDays: toOptionalNumber(row.expireDays), expireDays:
row.rewardType === 'GOLD' || row.rewardType === 'NONE'
? undefined
: toOptionalNumber(row.expireDays),
id: row.id, id: row.id,
level: rewardLevel.value, level: rewardLevel.value,
rewardAmount: toNumber(row.rewardAmount, 1), rewardAmount:
rewardCover: row.rewardCover, row.rewardType === 'NONE' ? 0 : toNumber(row.rewardAmount, 1),
rewardCover: row.rewardType === 'NONE' ? '' : row.rewardCover,
rewardItemId: rewardItemId:
row.rewardType === 'GOLD' ? undefined : toOptionalString(row.rewardItemId), row.rewardType === 'GOLD' || row.rewardType === 'NONE'
rewardName: row.rewardName.trim(), ? undefined
: toOptionalString(row.rewardItemId),
rewardName: row.rewardType === 'NONE' ? '轮空' : row.rewardName.trim(),
rewardScene: row.rewardScene, rewardScene: row.rewardScene,
rewardType: row.rewardType, rewardType: row.rewardType,
sort: toNumber(row.sort, 1), sort: toNumber(row.sort, 1),
@ -605,7 +783,7 @@ watch(rewardLevel, () => {
:data-source="levelRows" :data-source="levelRows"
:loading="levelLoading" :loading="levelLoading"
:pagination="false" :pagination="false"
:scroll="{ x: 1420 }" :scroll="{ x: 1240 }"
row-key="level" row-key="level"
> >
<template #bodyCell="{ column, record }"> <template #bodyCell="{ column, record }">
@ -620,24 +798,70 @@ watch(rewardLevel, () => {
class="table-number" class="table-number"
/> />
</template> </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'"> <template v-else-if="column.key === 'shakeThresholdPercent'">
<Input <Input
v-model:value="record.shakeThresholdPercent" v-model:value="record.shakeThresholdPercent"
class="table-input" class="table-input"
/> />
</template> </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'"> <template v-else-if="column.key === 'enabled'">
<Switch v-model:checked="record.enabled" /> <Switch v-model:checked="record.enabled" />
</template> </template>
@ -676,6 +900,7 @@ watch(rewardLevel, () => {
<Button @click="addRewardRow('TOP1')">添加贡献第一</Button> <Button @click="addRewardRow('TOP1')">添加贡献第一</Button>
<Button @click="addRewardRow('IGNITE')">添加点火人</Button> <Button @click="addRewardRow('IGNITE')">添加点火人</Button>
<Button @click="addRewardRow('IN_ROOM')">添加在房用户</Button> <Button @click="addRewardRow('IN_ROOM')">添加在房用户</Button>
<Button @click="addRewardRow('IN_ROOM', 'NONE')">添加在房轮空</Button>
<Button type="primary" :loading="rewardSaving" @click="saveRewards"> <Button type="primary" :loading="rewardSaving" @click="saveRewards">
保存当前等级奖励 保存当前等级奖励
</Button> </Button>
@ -695,33 +920,67 @@ watch(rewardLevel, () => {
v-model:value="record.rewardScene" v-model:value="record.rewardScene"
:options="sceneOptions" :options="sceneOptions"
class="table-select" class="table-select"
@change="() => handleRewardSceneChange(record)"
/> />
</template> </template>
<template v-else-if="column.key === 'rewardType'"> <template v-else-if="column.key === 'rewardType'">
<Select <Select
v-model:value="record.rewardType" v-model:value="record.rewardType"
:options="rewardTypeOptions" :options="rewardTypeOptionsForRow(record)"
class="table-select" class="table-select"
@change="() => handleRewardTypeChange(record)" @change="() => handleRewardTypeChange(record)"
/> />
</template> </template>
<template v-else-if="column.key === 'rewardItemId'"> <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 <Input
v-model:value="record.rewardItemId" v-model:value="record.rewardName"
:disabled="record.rewardType === 'GOLD'" :disabled="record.rewardType === 'NONE'"
class="table-input-small" class="table-input-small"
/> />
</template> </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'"> <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>
<template v-else-if="column.key === 'rewardAmount'"> <template v-else-if="column.key === 'rewardAmount'">
<InputNumber <InputNumber
v-model:value="record.rewardAmount" v-model:value="record.rewardAmount"
:min="1" :disabled="record.rewardType === 'NONE'"
:min="record.rewardType === 'NONE' ? 0 : 1"
:precision="0" :precision="0"
class="table-number" class="table-number"
/> />
@ -729,7 +988,9 @@ watch(rewardLevel, () => {
<template v-else-if="column.key === 'expireDays'"> <template v-else-if="column.key === 'expireDays'">
<InputNumber <InputNumber
v-model:value="record.expireDays" v-model:value="record.expireDays"
:disabled="record.rewardType === 'GOLD'" :disabled="
record.rewardType === 'GOLD' || record.rewardType === 'NONE'
"
:min="0" :min="0"
:precision="0" :precision="0"
class="table-number" class="table-number"
@ -777,6 +1038,16 @@ watch(rewardLevel, () => {
</Card> </Card>
</TabPane> </TabPane>
</Tabs> </Tabs>
<ResourceSelectDrawer
:default-type="resourcePickerDefaultType"
:fixed-type="resourcePickerFixedType"
:open="resourcePickerOpen"
:sys-origin="selectedSysOrigin"
:title="resourcePickerTitle"
@close="resourcePickerOpen = false"
@select="handleResourceSelect"
/>
</div> </div>
</Page> </Page>
</template> </template>
@ -843,4 +1114,60 @@ watch(rewardLevel, () => {
.table-number { .table-number {
width: 110px; 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> </style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
4934