Compare commits

...

2 Commits

Author SHA1 Message Date
zhx
bd3dde8bac 去掉金币显示 2026-05-19 15:25:16 +08:00
zhx
d0c917f30f 礼物批量导入 2026-05-19 14:10:41 +08:00
8 changed files with 2099 additions and 339 deletions

View File

@ -0,0 +1,268 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { Pagination, Table } from 'antdv-next';
defineOptions({ name: 'AdminConfigTable' });
const props = withDefaults(
defineProps<{
autoHeight?: boolean;
columns: Array<Record<string, any>>;
current: number;
dataSource: Array<Record<string, any>>;
loading?: boolean;
pageSize: number;
plain?: boolean;
rowKey?: string;
scrollX?: number | string;
showSizeChanger?: boolean;
total: number;
}>(),
{
autoHeight: false,
loading: false,
plain: false,
rowKey: 'id',
scrollX: 1000,
showSizeChanger: true,
},
);
const emit = defineEmits<{
pageChange: [page: number, pageSize: number];
}>();
const tableAreaRef = ref<HTMLDivElement | null>(null);
const pagerRef = ref<HTMLDivElement | null>(null);
const tableScrollY = ref(320);
let tableResizeObserver: null | ResizeObserver = null;
const tableScroll = computed(() => {
const scroll: Record<string, any> = { x: props.scrollX };
if (props.autoHeight) {
scroll.y = tableScrollY.value;
}
return scroll;
});
function stringifyCellValue(value: any) {
if (value === undefined || value === null || value === '') {
return '-';
}
return String(value);
}
function getColumnValue(record: Record<string, any>, column: Record<string, any>) {
const dataIndex = column?.dataIndex;
if (Array.isArray(dataIndex)) {
let value = record;
for (const key of dataIndex) {
value = value?.[String(key)];
}
return value;
}
if (dataIndex === undefined || dataIndex === null) {
return undefined;
}
return record?.[String(dataIndex)];
}
function updateTableScrollY() {
if (!props.autoHeight) {
return;
}
const area = tableAreaRef.value;
if (!area) {
return;
}
const headerHeight =
area.querySelector('.ant-table-thead')?.getBoundingClientRect().height || 48;
const scrollbarReserve = 8;
tableScrollY.value = Math.max(
160,
Math.floor(area.clientHeight - headerHeight - scrollbarReserve),
);
}
async function refreshTableScroll() {
await nextTick();
updateTableScrollY();
}
onMounted(async () => {
await refreshTableScroll();
if (!props.autoHeight || typeof ResizeObserver === 'undefined') {
return;
}
tableResizeObserver = new ResizeObserver(() => {
void refreshTableScroll();
});
if (tableAreaRef.value) {
tableResizeObserver.observe(tableAreaRef.value);
}
if (pagerRef.value) {
tableResizeObserver.observe(pagerRef.value);
}
window.addEventListener('resize', updateTableScrollY);
});
onBeforeUnmount(() => {
tableResizeObserver?.disconnect();
window.removeEventListener('resize', updateTableScrollY);
});
</script>
<template>
<div
class="admin-config-table-card"
:class="{ 'admin-config-table-card--plain': plain }"
>
<div ref="tableAreaRef" class="admin-config-table-area">
<Table
class="admin-config-table"
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="false"
:row-key="rowKey"
:scroll="tableScroll"
>
<template #headerCell="slotProps">
<slot name="headerCell" v-bind="slotProps">
{{ slotProps.column.title }}
</slot>
</template>
<template #bodyCell="slotProps">
<slot name="bodyCell" v-bind="slotProps">
{{ stringifyCellValue(getColumnValue(slotProps.record, slotProps.column)) }}
</slot>
</template>
</Table>
</div>
<div ref="pagerRef" class="admin-config-table-pager">
<Pagination
:current="current"
:page-size="pageSize"
:total="total"
:show-size-changer="showSizeChanger"
@change="(page: number, size: number) => emit('pageChange', page, size)"
@show-size-change="(page: number, size: number) => emit('pageChange', page, size)"
/>
</div>
</div>
</template>
<style scoped>
.admin-config-table-card {
background: #fff;
border: 1px solid rgb(229 231 235);
border-radius: 8px;
box-shadow: none;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.admin-config-table-card--plain {
border: 0;
border-radius: 0;
}
.admin-config-table-area {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.admin-config-table {
height: 100%;
width: 100%;
}
.admin-config-table :deep(.ant-table) {
color: rgb(52 64 84);
font-size: var(--density-table-body-font-size);
}
.admin-config-table :deep(.ant-spin-nested-loading),
.admin-config-table :deep(.ant-spin-container) {
height: 100%;
min-height: 0;
}
.admin-config-table :deep(.ant-spin-container) {
display: flex;
flex-direction: column;
}
.admin-config-table :deep(.ant-table) {
flex: 1 1 auto;
min-height: 0;
}
.admin-config-table :deep(.ant-table-container) {
min-height: 0;
}
.admin-config-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
border-bottom-color: rgb(237 240 244);
color: rgb(71 84 103);
font-size: var(--density-table-header-font-size);
font-weight: 500;
height: var(--density-table-header-height);
line-height: 1.35;
padding: 8px var(--density-table-cell-padding-x);
}
.admin-config-table :deep(.ant-table-thead > tr > th::before) {
background-color: rgb(237 240 244) !important;
}
.admin-config-table :deep(.ant-table-tbody > tr > td) {
border-bottom-color: rgb(237 240 244);
height: var(--density-table-row-height);
line-height: 1.35;
padding: 3px var(--density-table-cell-padding-x);
}
.admin-config-table :deep(.ant-table-body) {
overflow-y: auto !important;
}
.admin-config-table :deep(.switch-column) {
padding-left: var(--density-table-fixed-cell-padding-x) !important;
padding-right: var(--density-table-fixed-cell-padding-x) !important;
}
.admin-config-table :deep(.action-column) {
padding-left: var(--density-table-fixed-cell-padding-x) !important;
padding-right: var(--density-table-fixed-cell-padding-x) !important;
}
.admin-config-table :deep(.ant-table-tbody > tr:hover > td) {
background: rgb(250 252 255);
}
.admin-config-table :deep(.ant-table-cell-fix-left),
.admin-config-table :deep(.ant-table-cell-fix-right) {
background: #fff;
}
.admin-config-table :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-left),
.admin-config-table :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-right) {
background: rgb(250 252 255);
}
.admin-config-table-pager {
border-top: 1px solid rgb(237 240 244);
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
padding: 10px 12px;
}
</style>

View File

@ -0,0 +1,104 @@
const RESOURCE_NAME_TRANSLATION_ENTRIES = ([
['头像框', 'avatar frame'],
['座驾', 'ride'],
['坐骑', 'mount'],
['勋章', 'badge'],
['徽章', 'badge'],
['管理员', 'admin'],
['圣诞', 'christmas'],
['新年', 'new year'],
['情人节', 'valentine'],
['万圣节', 'halloween'],
['锦鲤', 'koi'],
['熊猫', 'panda'],
['独角兽', 'unicorn'],
['天使', 'angel'],
['恶魔', 'devil'],
['皇冠', 'crown'],
['国王', 'king'],
['女王', 'queen'],
['玫瑰', 'rose'],
['星光', 'starlight'],
['星星', 'star'],
['月亮', 'moon'],
['太阳', 'sun'],
['彩虹', 'rainbow'],
['火焰', 'flame'],
['冰雪', 'snow'],
['蓝色', 'blue'],
['红色', 'red'],
['紫色', 'purple'],
['粉色', 'pink'],
['金色', 'gold'],
['银色', 'silver'],
['黑色', 'black'],
['白色', 'white'],
['绿色', 'green'],
['黄色', 'yellow'],
['钻石', 'diamond'],
['水晶', 'crystal'],
['幸运', 'lucky'],
['浪漫', 'romantic'],
['荣耀', 'glory'],
['守护', 'guard'],
['贵族', 'noble'],
['梦幻', 'dream'],
['闪耀', 'shining'],
['限定', 'limited'],
['豪华', 'deluxe'],
['高级', 'premium'],
['经典', 'classic'],
['热气球', 'hot air balloon'],
['跑车', 'sports car'],
['飞船', 'spaceship'],
['游艇', 'yacht'],
['摩托', 'motorcycle'],
['龙', 'dragon'],
['凤', 'phoenix'],
['虎', 'tiger'],
['兔', 'rabbit'],
['猫', 'cat'],
['狗', 'dog'],
['鱼', 'fish'],
] as Array<[string, string]>).toSorted(
(current, next) => next[0].length - current[0].length,
);
export function normalizeResourceBaseName(value: string) {
const name = value
.replace(/\.[^.]+$/, '')
.replace(/([_-]?(cover|covers|source|resource|resources|ios|android|expand))$/i, '')
.replace(/([_-]?(封面|资源))$/i, '')
.trim();
return name || value.replace(/\.[^.]+$/, '') || 'resource';
}
export function normalizeResourceCode(value: string) {
const baseName = normalizeResourceBaseName(value);
const translated = translateResourceName(baseName);
return slugifyResourceCode(translated) || slugifyResourceCode(baseName) || 'resource';
}
function translateResourceName(value: string) {
let result = value.trim();
RESOURCE_NAME_TRANSLATION_ENTRIES.forEach(([source, target]) => {
result = result.replaceAll(source, ` ${target} `);
});
return [...result]
.map((char) => {
if (/[\u4E00-\u9FFF]/.test(char)) {
return ` u${char.codePointAt(0)?.toString(16) || ''} `;
}
return char;
})
.join('');
}
function slugifyResourceCode(value: string) {
return value
.normalize('NFKC')
.replaceAll(/[^\d A-Za-z]+/g, ' ')
.trim()
.replaceAll(/\s+/g, '_')
.toLowerCase();
}

File diff suppressed because it is too large Load Diff

View File

@ -38,9 +38,10 @@ import {
regionConfigTable,
} from '#/api/legacy/system';
import AccountInput from '#/components/account-input.vue';
import { copyText } from '#/views/operate/shared';
import AdminConfigTable from '#/views/_shared/admin-config-table.vue';
import { formatDate, getAllowedSysOrigins } from '#/views/system/shared';
import GiftBatchDrawer from './components/gift-batch-drawer.vue';
import GiftFormModal from './components/gift-form-modal.vue';
import { GIFT_CONFIG_TAB_OPTIONS } from './constants';
@ -87,6 +88,7 @@ const settingQuery = reactive({
});
const giftFormOpen = ref(false);
const giftBatchOpen = ref(false);
const activeGiftRow = ref<null | Record<string, any>>(null);
const addRegionOpen = ref(false);
const addRegionSaving = ref(false);
@ -130,7 +132,6 @@ const cpForm = reactive({
});
const settingColumns = [
{ dataIndex: 'id', key: 'id', title: 'ID', width: 120 },
{ dataIndex: 'giftCode', key: 'giftCode', title: 'Code', width: 160 },
{ dataIndex: 'giftPhoto', key: 'giftPhoto', title: '封面', width: 100 },
{ dataIndex: 'giftName', key: 'giftName', title: '名称', width: 160 },
@ -139,10 +140,10 @@ const settingColumns = [
{ dataIndex: 'regionNameStr', key: 'regionNameStr', title: '区域', width: 180 },
{ dataIndex: 'typeName', key: 'typeName', title: '类型', width: 120 },
{ dataIndex: 'sort', key: 'sort', title: '排序权重', width: 100 },
{ dataIndex: 'del', key: 'del', title: '上/下架', width: 100 },
{ align: 'center' as const, className: 'switch-column', dataIndex: 'del', fixed: 'right' as const, key: 'del', title: '上/下架', width: 84 },
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
{ dataIndex: 'userBaseInfo', key: 'userBaseInfo', title: '专属礼物归属人', width: 180 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 180 },
{ align: 'center' as const, className: 'action-column', dataIndex: 'actions', fixed: 'right' as const, key: 'actions', title: '操作', width: 84 },
];
const regionOptions = computed(() =>
@ -249,20 +250,34 @@ function handleWeekStarPageChange(page: number, pageSize: number) {
void loadWeekStar();
}
async function handleCopy(url?: string) {
if (!url) {
return;
}
await copyText(getAccessImgUrl(url));
message.success('复制成功');
}
async function handleSwitch(record: Record<string, any>) {
await switchDelStatus(record.id, record.del);
message.success('操作成功');
await loadSetting(false);
}
function stringifyCellValue(value: any) {
if (value === undefined || value === null || value === '') {
return '-';
}
return String(value);
}
function getColumnValue(record: Record<string, any>, column: Record<string, any>) {
const dataIndex = column?.dataIndex;
if (Array.isArray(dataIndex)) {
let value = record;
for (const key of dataIndex) {
value = value?.[String(key)];
}
return value;
}
if (dataIndex === undefined || dataIndex === null) {
return undefined;
}
return record?.[String(dataIndex)];
}
function openGiftCreate() {
activeGiftRow.value = null;
giftFormOpen.value = true;
@ -434,26 +449,30 @@ async function handleCpSave() {
<Button type="primary" @click="openGiftCreate">
新增礼物
</Button>
<Button type="primary" @click="giftBatchOpen = true">
批量导入
</Button>
<Button danger @click="openAddRegionModal">
添加全部礼物区域
</Button>
</Space>
<Table
<AdminConfigTable
:columns="settingColumns"
:data-source="settingList"
:loading="settingLoading"
:pagination="false"
:current="settingQuery.cursor"
:page-size="settingQuery.limit"
plain
row-key="id"
:scroll="{ x: 1680 }"
:scroll-x="1560"
:total="settingTotal"
@page-change="handleSettingPageChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'giftPhoto'">
<div class="week-star-gift">
<Image :src="getAccessImgUrl(record.giftPhoto)" class="gift-image" />
<Button size="small" type="link" @click="handleCopy(record.giftPhoto)">
复制
</Button>
</div>
</template>
<template v-else-if="column.key === 'del'">
@ -473,19 +492,11 @@ async function handleCpSave() {
编辑
</Button>
</template>
<template v-else>
{{ stringifyCellValue(getColumnValue(record, column)) }}
</template>
</template>
</Table>
<div class="pager">
<Pagination
:current="settingQuery.cursor"
:page-size="settingQuery.limit"
:total="settingTotal"
show-size-changer
@change="handleSettingPageChange"
@show-size-change="handleSettingPageChange"
/>
</div>
</AdminConfigTable>
</div>
<div v-else-if="activeTab === 'weekStar'">
@ -588,6 +599,14 @@ async function handleCpSave() {
@success="loadSetting(true)"
/>
<GiftBatchDrawer
:initial-sys-origin="settingQuery.sysOrigin"
:open="giftBatchOpen"
:sys-origin-options="sysOriginOptions"
@close="giftBatchOpen = false"
@success="loadSetting(true)"
/>
<Modal
:confirm-loading="addRegionSaving"
:open="addRegionOpen"

View File

@ -23,6 +23,7 @@ import {
simpleUploadFile,
} from '#/api/legacy/oss';
import { addPropsSource } from '#/api/legacy/props';
import { normalizeResourceCode } from '#/views/_shared/resource-code';
import {
BADGE_DISPLAY_TYPE_OPTIONS,
@ -127,71 +128,6 @@ const STRUCTURED_ASSET_KIND_MAP: Record<string, StructuredAssetKind> = {
animation: 'source',
cover: 'cover',
};
const RESOURCE_NAME_TRANSLATION_ENTRIES = ([
['头像框', 'avatar frame'],
['座驾', 'ride'],
['坐骑', 'mount'],
['勋章', 'badge'],
['徽章', 'badge'],
['管理员', 'admin'],
['圣诞', 'christmas'],
['新年', 'new year'],
['情人节', 'valentine'],
['万圣节', 'halloween'],
['锦鲤', 'koi'],
['熊猫', 'panda'],
['独角兽', 'unicorn'],
['天使', 'angel'],
['恶魔', 'devil'],
['皇冠', 'crown'],
['国王', 'king'],
['女王', 'queen'],
['玫瑰', 'rose'],
['星光', 'starlight'],
['星星', 'star'],
['月亮', 'moon'],
['太阳', 'sun'],
['彩虹', 'rainbow'],
['火焰', 'flame'],
['冰雪', 'snow'],
['蓝色', 'blue'],
['红色', 'red'],
['紫色', 'purple'],
['粉色', 'pink'],
['金色', 'gold'],
['银色', 'silver'],
['黑色', 'black'],
['白色', 'white'],
['绿色', 'green'],
['黄色', 'yellow'],
['钻石', 'diamond'],
['水晶', 'crystal'],
['幸运', 'lucky'],
['浪漫', 'romantic'],
['荣耀', 'glory'],
['守护', 'guard'],
['贵族', 'noble'],
['梦幻', 'dream'],
['闪耀', 'shining'],
['限定', 'limited'],
['豪华', 'deluxe'],
['高级', 'premium'],
['经典', 'classic'],
['热气球', 'hot air balloon'],
['跑车', 'sports car'],
['飞船', 'spaceship'],
['游艇', 'yacht'],
['摩托', 'motorcycle'],
['龙', 'dragon'],
['凤', 'phoenix'],
['虎', 'tiger'],
['兔', 'rabbit'],
['猫', 'cat'],
['狗', 'dog'],
['鱼', 'fish'],
] as Array<[string, string]>).toSorted(
(current, next) => next[0].length - current[0].length,
);
function isAmountRequiredByType(type: string) {
return !['CUSTOMIZE', 'FRAGMENTS'].includes(type);
@ -348,43 +284,8 @@ function normalizeAmount(value: unknown) {
return Number.isFinite(amount) && amount >= 0 ? amount : 0;
}
function normalizeBaseName(value: string) {
const name = value
.replace(/\.[^.]+$/, '')
.replace(/([_-]?(cover|covers|source|resource|resources|ios|android|expand))$/i, '')
.replace(/([_-]?(封面|资源))$/i, '')
.trim();
return name || value.replace(/\.[^.]+$/, '') || 'resource';
}
function normalizeCode(value: string) {
const baseName = normalizeBaseName(value);
const translated = translateResourceName(baseName);
return slugifyResourceCode(translated) || slugifyResourceCode(baseName) || 'resource';
}
function translateResourceName(value: string) {
let result = value.trim();
RESOURCE_NAME_TRANSLATION_ENTRIES.forEach(([source, target]) => {
result = result.replaceAll(source, ` ${target} `);
});
return [...result]
.map((char) => {
if (/[\u4E00-\u9FFF]/.test(char)) {
return ` u${char.codePointAt(0)?.toString(16) || ''} `;
}
return char;
})
.join('');
}
function slugifyResourceCode(value: string) {
return value
.normalize('NFKC')
.replaceAll(/[^\d A-Za-z]+/g, ' ')
.trim()
.replaceAll(/\s+/g, '_')
.toLowerCase();
return normalizeResourceCode(value);
}
function getPathFileName(path: string) {

View File

@ -2,8 +2,6 @@
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
} from 'vue';
@ -19,16 +17,13 @@ import { useAccessStore } from '@vben/stores';
import {
Button,
Card,
Empty,
Input,
InputNumber,
message,
Pagination,
Popover,
Select,
Switch,
Table,
Tooltip,
} from 'antdv-next';
@ -39,6 +34,7 @@ import {
pagePropsSource,
updatePropsSource,
} from '#/api/legacy/props';
import AdminConfigTable from '#/views/_shared/admin-config-table.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
import ResourceBatchDrawer from './components/resource-batch-drawer.vue';
@ -80,20 +76,15 @@ const amountSavingId = ref<null | string>(null);
const amountDraft = ref<null | number>(null);
const filterOpen = reactive<Record<string, boolean>>({});
const pageActionOpen = ref(false);
const tableAreaRef = ref<HTMLDivElement | null>(null);
const pagerRef = ref<HTMLDivElement | null>(null);
const tableScrollY = ref(320);
const amountFormatter = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 2,
});
const tableScroll = computed(() => ({ x: 1000, y: tableScrollY.value }));
const adminFreeOptions = [
{ label: '是', value: true as any },
{ label: '否', value: false as any },
];
const DEFAULT_STORE_CURRENCY_TYPES = 'GOLD';
const DEFAULT_STORE_VALID_DAYS = '7';
let tableResizeObserver: null | ResizeObserver = null;
const query = reactive({
cursor: 1,
@ -139,6 +130,21 @@ function stringifyCellValue(value: any) {
return String(value);
}
function getColumnValue(record: Record<string, any>, column: Record<string, any>) {
const dataIndex = column?.dataIndex;
if (Array.isArray(dataIndex)) {
let value = record;
for (const key of dataIndex) {
value = value?.[String(key)];
}
return value;
}
if (dataIndex === undefined || dataIndex === null) {
return undefined;
}
return record?.[String(dataIndex)];
}
function isFilterableColumn(key: unknown) {
return filterableColumnKeys.has(String(key));
}
@ -264,7 +270,6 @@ async function loadData(reset = false) {
total.value = result.total || 0;
} finally {
loading.value = false;
void refreshTableScroll();
}
}
@ -289,25 +294,6 @@ async function attachStoreCommodities(records: Array<Record<string, any>>) {
}));
}
function updateTableScrollY() {
const area = tableAreaRef.value;
if (!area) {
return;
}
const headerHeight =
area.querySelector('.ant-table-thead')?.getBoundingClientRect().height || 48;
const scrollbarReserve = 8;
tableScrollY.value = Math.max(
160,
Math.floor(area.clientHeight - headerHeight - scrollbarReserve),
);
}
async function refreshTableScroll() {
await nextTick();
updateTableScrollY();
}
function handlePageChange(page: number, pageSize: number) {
query.cursor = page;
query.limit = pageSize;
@ -499,28 +485,6 @@ function openEdit(record: Record<string, any>) {
formOpen.value = true;
}
onMounted(async () => {
await nextTick();
void refreshTableScroll();
if (typeof ResizeObserver !== 'undefined') {
tableResizeObserver = new ResizeObserver(() => {
void refreshTableScroll();
});
if (tableAreaRef.value) {
tableResizeObserver.observe(tableAreaRef.value);
}
if (pagerRef.value) {
tableResizeObserver.observe(pagerRef.value);
}
}
window.addEventListener('resize', updateTableScrollY);
});
onBeforeUnmount(() => {
tableResizeObserver?.disconnect();
window.removeEventListener('resize', updateTableScrollY);
});
loadData(true);
</script>
@ -539,17 +503,18 @@ loadData(true);
@success="loadData()"
/>
<Card v-if="canQuery" class="resource-card">
<div ref="tableAreaRef" class="resource-table-area">
<Table
class="resource-table"
:columns="columns"
:data-source="list"
:loading="loading"
:pagination="false"
row-key="id"
:scroll="tableScroll"
>
<AdminConfigTable
v-if="canQuery"
auto-height
:columns="columns"
:current="query.cursor"
:data-source="list"
:loading="loading"
:page-size="query.limit"
:scroll-x="1000"
:total="total"
@page-change="handlePageChange"
>
<template #headerCell="{ column }">
<template v-if="column.key === 'actions'">
<div class="column-title column-title--actions">
@ -786,24 +751,14 @@ loadData(true);
</Tooltip>
</div>
</template>
<template v-else>
{{ stringifyCellValue(getColumnValue(record, column)) }}
</template>
</Table>
</div>
<div ref="pagerRef" class="pager">
<Pagination
:current="query.cursor"
:page-size="query.limit"
:total="total"
show-size-changer
@change="handlePageChange"
@show-size-change="handlePageChange"
/>
</div>
</Card>
<Card v-else class="resource-card">
</template>
</AdminConfigTable>
<div v-else class="resource-permission-empty">
<Empty description="暂无查看权限" />
</Card>
</div>
<ResourceBatchDrawer
:initial-sys-origin="query.sysOrigin"
@ -828,14 +783,6 @@ loadData(true);
min-height: 0;
}
.pager {
border-top: 1px solid rgb(237 240 244);
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
padding: 10px 12px;
}
.column-title {
align-items: center;
display: inline-flex;
@ -935,110 +882,15 @@ loadData(true);
color: rgb(22 119 255);
}
.resource-card {
.resource-permission-empty {
align-items: center;
background: #fff;
border: 1px solid rgb(229 231 235);
border-radius: 8px;
box-shadow: none;
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
justify-content: center;
min-height: 0;
overflow: hidden;
}
.resource-card :deep(.ant-card-body) {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
padding: 0;
}
.resource-table-area {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.resource-table :deep(.ant-table) {
color: rgb(52 64 84);
font-size: var(--density-table-body-font-size);
}
.resource-table {
height: 100%;
width: 100%;
}
.resource-table :deep(.ant-spin-nested-loading),
.resource-table :deep(.ant-spin-container) {
height: 100%;
min-height: 0;
}
.resource-table :deep(.ant-spin-container) {
display: flex;
flex-direction: column;
}
.resource-table :deep(.ant-table) {
flex: 1 1 auto;
min-height: 0;
}
.resource-table :deep(.ant-table-container) {
min-height: 0;
}
.resource-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
border-bottom-color: rgb(237 240 244);
color: rgb(71 84 103);
font-size: var(--density-table-header-font-size);
font-weight: 500;
line-height: 1.35;
height: var(--density-table-header-height);
padding: 8px var(--density-table-cell-padding-x);
}
.resource-table :deep(.ant-table-thead > tr > th::before) {
background-color: rgb(237 240 244) !important;
}
.resource-table :deep(.ant-table-tbody > tr > td) {
border-bottom-color: rgb(237 240 244);
height: var(--density-table-row-height);
line-height: 1.35;
padding: 3px var(--density-table-cell-padding-x);
}
.resource-table :deep(.ant-table-body) {
overflow-y: auto !important;
}
.resource-table :deep(.switch-column) {
padding-left: var(--density-table-fixed-cell-padding-x) !important;
padding-right: var(--density-table-fixed-cell-padding-x) !important;
}
.resource-table :deep(.action-column) {
padding-left: var(--density-table-fixed-cell-padding-x) !important;
padding-right: var(--density-table-fixed-cell-padding-x) !important;
}
.resource-table :deep(.ant-table-tbody > tr:hover > td) {
background: rgb(250 252 255);
}
.resource-table :deep(.ant-table-cell-fix-left),
.resource-table :deep(.ant-table-cell-fix-right) {
background: #fff;
}
.resource-table :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-left),
.resource-table :deep(.ant-table-tbody > tr:hover > .ant-table-cell-fix-right) {
background: rgb(250 252 255);
}
.compact-switch {

View File

@ -75,6 +75,7 @@ const roomDetailsOpen = ref(false);
const activeRoomId = ref<number | string>('');
const form = reactive<Record<string, any>>({
autoRewardEnabled: true,
configured: false,
enabled: false,
id: 0,
@ -140,6 +141,7 @@ const enabledLevelCount = computed(() =>
function normalizeConfig(result: Record<string, any> | null | undefined) {
const value = result || {};
form.autoRewardEnabled = value.autoRewardEnabled !== false;
form.configured = Boolean(value.configured);
form.enabled = Boolean(value.enabled);
form.id = value.id || 0;
@ -282,6 +284,7 @@ async function submitForm() {
saving.value = true;
try {
await saveRoomTurnoverRewardConfig({
autoRewardEnabled: Boolean(form.autoRewardEnabled),
enabled: Boolean(form.enabled),
id: form.id || 0,
levelConfigs: (form.levelConfigs || []).map((item: Record<string, any>) => ({
@ -386,6 +389,9 @@ watch(
<Tag :color="form.enabled ? 'success' : 'default'">
{{ form.enabled ? '已启用' : '已关闭' }}
</Tag>
<Tag :color="form.autoRewardEnabled ? 'success' : 'warning'">
{{ form.autoRewardEnabled ? '自动发放' : '手动领取' }}
</Tag>
<Tag color="processing">按达到的最高档发放一次</Tag>
<Button :loading="loading" @click="reloadAll">刷新</Button>
</div>
@ -394,6 +400,8 @@ watch(
<Space align="center" wrap>
<span class="field-label">启用</span>
<Switch v-model:checked="form.enabled" />
<span class="field-label">自动发放</span>
<Switch v-model:checked="form.autoRewardEnabled" />
<span class="field-label">结算时区</span>
<Input v-model:value="form.timezone" style="width: 180px" />
<span class="sub-text">更新时间{{ form.updateTime || '-' }}</span>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
@ -9,16 +9,16 @@ import {
Card,
Input,
InputNumber,
message,
Select,
Space,
Spin,
Switch,
Table,
Tabs,
TabPane,
Tabs,
Tag,
TextArea,
message,
} from 'antdv-next';
import {
@ -37,6 +37,30 @@ defineOptions({ name: 'ResidentVoiceRoomRocketPage' });
const RIYADH_TIMEZONE = 'Asia/Riyadh';
const DEFAULT_MAX_LEVEL = 6;
const RULE_TEXT_LOCALES = [
{ label: 'English', value: 'en' },
{ label: '中文', value: 'zh-CN' },
];
const RULE_TEXT_FONT_SIZE_OPTIONS = [
{ label: '12px', value: '12px' },
{ label: '14px', value: '14px' },
{ label: '16px', value: '16px' },
{ label: '18px', value: '18px' },
{ label: '20px', value: '20px' },
{ label: '24px', value: '24px' },
{ label: '28px', value: '28px' },
{ label: '32px', value: '32px' },
];
const RULE_TEXT_COLOR_OPTIONS = [
{ label: '默认黑', value: '#111827' },
{ label: '灰色', value: '#6b7280' },
{ label: '红色', value: '#ef4444' },
{ label: '橙色', value: '#f97316' },
{ label: '黄色', value: '#eab308' },
{ label: '绿色', value: '#22c55e' },
{ label: '蓝色', value: '#3b82f6' },
{ label: '紫色', value: '#8b5cf6' },
];
type LevelRow = {
enabled: boolean;
@ -54,15 +78,15 @@ type RewardRow = {
enabled: boolean;
expireDays?: null | number;
id?: number | string;
localKey: string;
level: number;
localKey: string;
rewardAmount: number;
rewardCopies: number;
rewardCover: string;
rewardItemId?: null | number | string;
rewardName: string;
rewardScene: string;
rewardType: string;
rewardCopies: number;
sort: number;
weight: number;
};
@ -109,19 +133,28 @@ const configForm = reactive({
maxLevel: DEFAULT_MAX_LEVEL,
rankingTopLimit: 100,
rewardRecordKeepDays: 35,
ruleTextText: '{}',
ruleTextMap: Object.fromEntries(
RULE_TEXT_LOCALES.map((item) => [item.value, '']),
) as Record<string, string>,
timezone: RIYADH_TIMEZONE,
updateTime: '',
});
const levelRows = ref<LevelRow[]>([]);
const rewardRows = ref<RewardRow[]>([]);
const ruleTextLocale = ref(RULE_TEXT_LOCALES[0]?.value || 'zh-CN');
const ruleTextExtra = ref<Record<string, unknown>>({});
const ruleEditorRef = ref<HTMLElement | null>(null);
const ruleTextColor = ref(RULE_TEXT_COLOR_OPTIONS[0]?.value || '#111827');
const ruleTextFontSize = ref(RULE_TEXT_FONT_SIZE_OPTIONS[2]?.value || '16px');
const ruleTextSavedRange = ref<null | Range>(null);
const resourcePickerOpen = ref(false);
const resourcePickerTitle = ref('选择资源');
const resourcePickerDefaultType = ref('ALL_PROPS');
const resourcePickerFixedType = ref(false);
const resourcePickerTarget = ref<
| null
| {
field: LevelResourceField;
kind: 'level';
@ -131,7 +164,6 @@ const resourcePickerTarget = ref<
kind: 'reward';
row: Record<string, any>;
}
| null
>(null);
const sceneOptions = [
@ -232,6 +264,199 @@ function stringifyJSON(value: unknown) {
return JSON.stringify(value, null, 2);
}
function trimEditorHTML(value: string) {
const html = String(value || '').trim();
return html === '<br>' || html === '<div><br></div>' ? '' : html;
}
function extractRuleTextHTML(value: unknown) {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const record = value as Record<string, unknown>;
const html = record.html ?? record.content ?? record.value;
return typeof html === 'string' ? html : JSON.stringify(value, null, 2);
}
return '';
}
function renderRuleTextEditor() {
void nextTick(() => {
const editor = ruleEditorRef.value;
if (!editor) {
return;
}
const html = configForm.ruleTextMap[ruleTextLocale.value] || '';
if (editor.innerHTML !== html) {
editor.innerHTML = html;
}
});
}
function normalizeRuleText(value: unknown) {
const nextMap = Object.fromEntries(
RULE_TEXT_LOCALES.map((item) => [item.value, '']),
) as Record<string, string>;
const extra: Record<string, unknown> = {};
if (typeof value === 'string') {
nextMap['zh-CN'] = value;
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
const localeSet = new Set(RULE_TEXT_LOCALES.map((item) => item.value));
Object.entries(value as Record<string, unknown>).forEach(([key, item]) => {
if (localeSet.has(key)) {
nextMap[key] = extractRuleTextHTML(item);
} else {
extra[key] = item;
}
});
}
configForm.ruleTextMap = nextMap;
ruleTextExtra.value = extra;
renderRuleTextEditor();
}
function sanitizeRuleTextHTML(value: string) {
const html = trimEditorHTML(value);
if (!html || typeof document === 'undefined') {
return html;
}
const container = document.createElement('div');
container.innerHTML = html;
container
.querySelectorAll('script, style, iframe, object, embed, link, meta')
.forEach((node) => node.remove());
container.querySelectorAll('*').forEach((node) => {
[...node.attributes].forEach((attr) => {
const name = attr.name.toLowerCase();
const attributeValue = attr.value.trim().toLowerCase();
if (name.startsWith('on') || attributeValue.startsWith('javascript:')) {
node.removeAttribute(attr.name);
}
});
});
return trimEditorHTML(container.innerHTML);
}
function syncRuleTextFromEditor(locale = ruleTextLocale.value) {
const editor = ruleEditorRef.value;
if (!editor || !locale) {
return;
}
configForm.ruleTextMap[locale] = sanitizeRuleTextHTML(editor.innerHTML);
}
function saveRuleTextSelection() {
if (typeof window === 'undefined') {
return;
}
const editor = ruleEditorRef.value;
const selection = window.getSelection();
if (!editor || !selection || selection.rangeCount === 0) {
return;
}
const range = selection.getRangeAt(0);
if (editor.contains(range.commonAncestorContainer)) {
ruleTextSavedRange.value = range.cloneRange();
}
}
function restoreRuleTextSelection() {
const editor = ruleEditorRef.value;
if (!editor || typeof window === 'undefined') {
return;
}
editor.focus();
const selection = window.getSelection();
const range = ruleTextSavedRange.value;
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
return;
}
selection.removeAllRanges();
selection.addRange(range);
}
function normalizeGeneratedFontTags(fontSize?: string) {
const editor = ruleEditorRef.value;
if (!editor) {
return;
}
editor.querySelectorAll('font').forEach((node) => {
const span = document.createElement('span');
const color = node.getAttribute('color');
span.innerHTML = node.innerHTML;
if (color) {
span.style.color = color;
}
if (fontSize && node.getAttribute('size')) {
span.style.fontSize = fontSize;
}
node.replaceWith(span);
});
}
function applyRuleTextCommand(command: string, value?: string) {
restoreRuleTextSelection();
document.execCommand(command, false, value);
normalizeGeneratedFontTags();
syncRuleTextFromEditor();
saveRuleTextSelection();
}
function clearRuleTextFormat() {
applyRuleTextCommand('removeFormat');
}
function applyRuleTextFontSize(value: string) {
ruleTextFontSize.value = value;
restoreRuleTextSelection();
document.execCommand('fontSize', false, '7');
normalizeGeneratedFontTags(value);
syncRuleTextFromEditor();
saveRuleTextSelection();
}
function applyRuleTextColor(value: string) {
ruleTextColor.value = value;
restoreRuleTextSelection();
document.execCommand('foreColor', false, value);
normalizeGeneratedFontTags();
syncRuleTextFromEditor();
saveRuleTextSelection();
}
function handleRuleTextFontSizeChange(value: string) {
applyRuleTextFontSize(value);
}
function handleRuleTextColorInput(event: Event) {
const target = event.target as HTMLInputElement | null;
if (target?.value) {
applyRuleTextColor(target.value);
}
}
function handleRuleTextPaste() {
window.setTimeout(() => syncRuleTextFromEditor());
}
function buildRuleTextPayload() {
syncRuleTextFromEditor();
const payload: Record<string, unknown> = { ...ruleTextExtra.value };
RULE_TEXT_LOCALES.forEach((item) => {
const html = sanitizeRuleTextHTML(configForm.ruleTextMap[item.value] || '');
if (html) {
payload[item.value] = html;
} else {
delete payload[item.value];
}
});
return payload;
}
function parseJSONText(value: string, label: string) {
const raw = String(value || '').trim();
if (!raw) {
@ -263,7 +488,7 @@ function toOptionalString(value: unknown) {
return text || undefined;
}
function normalizeConfig(value: Record<string, any> | null | undefined) {
function normalizeConfig(value: null | Record<string, any> | undefined) {
const next = value || {};
configForm.broadcastDurationSeconds = toNumber(
next.broadcastDurationSeconds,
@ -280,7 +505,7 @@ function normalizeConfig(value: Record<string, any> | null | undefined) {
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);
normalizeRuleText(next.ruleText);
configForm.timezone = RIYADH_TIMEZONE;
configForm.updateTime = String(next.updateTime || '');
}
@ -495,10 +720,7 @@ async function saveConfig() {
if (energyRuleJson === undefined) {
return;
}
const ruleText = parseJSONText(configForm.ruleTextText, '规则说明');
if (ruleText === undefined) {
return;
}
const ruleText = buildRuleTextPayload();
configSaving.value = true;
try {
const result = await saveVoiceRoomRocketConfig({
@ -554,7 +776,7 @@ function addRewardRow(scene = 'TOP1', rewardType = 'GOLD') {
rewardAmount: isNone ? 0 : 1,
rewardCover: '',
rewardItemId: null,
rewardName: isNone ? '轮空' : rewardType === 'GOLD' ? '金币' : '',
rewardName: isNone ? '轮空' : (rewardType === 'GOLD' ? '金币' : ''),
rewardScene: scene,
rewardType,
rewardCopies: 1,
@ -692,6 +914,11 @@ watch(
watch(rewardLevel, () => {
void loadRewards();
});
watch(ruleTextLocale, (_next, previous) => {
syncRuleTextFromEditor(previous);
renderRuleTextEditor();
});
</script>
<template>
@ -786,14 +1013,98 @@ watch(rewardLevel, () => {
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 class="field-row field-wide">
<span>规则说明富文本</span>
<div class="rule-editor-panel">
<div class="rule-editor-toolbar" @mousedown="saveRuleTextSelection">
<Space wrap>
<Select
v-model:value="ruleTextLocale"
:options="RULE_TEXT_LOCALES"
class="rule-editor-locale"
/>
<Select
v-model:value="ruleTextFontSize"
:options="RULE_TEXT_FONT_SIZE_OPTIONS"
class="rule-editor-size"
@change="handleRuleTextFontSizeChange"
/>
<div class="rule-editor-colors">
<button
v-for="item in RULE_TEXT_COLOR_OPTIONS"
:key="item.value"
:aria-label="item.label"
:class="{
'rule-color-swatch--active': ruleTextColor === item.value,
}"
:style="{ backgroundColor: item.value }"
class="rule-color-swatch"
type="button"
@mousedown.prevent="applyRuleTextColor(item.value)"
></button>
<input
:value="ruleTextColor"
aria-label="自定义字体颜色"
class="rule-color-picker"
type="color"
@input="handleRuleTextColorInput"
@mousedown="saveRuleTextSelection"
/>
</div>
</Space>
<Space wrap>
<Button
size="small"
@mousedown.prevent="applyRuleTextCommand('bold')"
>
<strong>B</strong>
</Button>
<Button
size="small"
@mousedown.prevent="applyRuleTextCommand('italic')"
>
<em>I</em>
</Button>
<Button
size="small"
@mousedown.prevent="applyRuleTextCommand('underline')"
>
<u>U</u>
</Button>
<Button
size="small"
@mousedown.prevent="
applyRuleTextCommand('insertUnorderedList')
"
>
无序列表
</Button>
<Button
size="small"
@mousedown.prevent="
applyRuleTextCommand('insertOrderedList')
"
>
有序列表
</Button>
<Button size="small" @mousedown.prevent="clearRuleTextFormat">
清除格式
</Button>
</Space>
</div>
<div
ref="ruleEditorRef"
class="rule-editor"
contenteditable="true"
data-placeholder="请输入规则说明"
@blur="syncRuleTextFromEditor()"
@keyup="saveRuleTextSelection"
@input="syncRuleTextFromEditor()"
@mouseup="saveRuleTextSelection"
@paste="handleRuleTextPaste"
></div>
</div>
</div>
</div>
<div class="actions">
@ -1152,6 +1463,76 @@ watch(rewardLevel, () => {
width: 100%;
}
.rule-editor-panel {
overflow: hidden;
width: 100%;
border: 1px solid #d9d9d9;
border-radius: 6px;
background: #fff;
}
.rule-editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid #f0f0f0;
}
.rule-editor-locale {
width: 120px;
}
.rule-editor-size {
width: 96px;
}
.rule-editor-colors {
display: inline-flex;
gap: 6px;
align-items: center;
}
.rule-color-swatch {
width: 22px;
height: 22px;
padding: 0;
cursor: pointer;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.rule-color-swatch--active {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(22 119 255 / 16%);
}
.rule-color-picker {
width: 28px;
height: 24px;
padding: 0;
cursor: pointer;
border: 1px solid #d9d9d9;
border-radius: 4px;
}
.rule-editor {
min-height: 180px;
max-height: 360px;
padding: 12px;
overflow: auto;
color: #111827;
outline: none;
}
.rule-editor:empty::before {
color: #bfbfbf;
content: attr(data-placeholder);
pointer-events: none;
}
.actions {
display: flex;
justify-content: flex-end;