礼物批量导入
This commit is contained in:
parent
abbf6d9556
commit
d0c917f30f
268
apps/src/views/_shared/admin-config-table.vue
Normal file
268
apps/src/views/_shared/admin-config-table.vue
Normal 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>
|
||||
104
apps/src/views/_shared/resource-code.ts
Normal file
104
apps/src/views/_shared/resource-code.ts
Normal 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();
|
||||
}
|
||||
1239
apps/src/views/operate/components/gift-batch-drawer.vue
Normal file
1239
apps/src/views/operate/components/gift-batch-drawer.vue
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user