Compare commits

...

15 Commits

Author SHA1 Message Date
zhx
1db5607cb8 礼物 2026-05-21 15:44:26 +08:00
zhx
74e1d20aa1 show manual salary role breakdown 2026-05-21 14:05:38 +08:00
zhx
710fe5406d 工资发放 2026-05-21 13:44:23 +08:00
zhx
5e51b460f4 增加工资发放 2026-05-21 11:12:43 +08:00
zhx
c7f7a7047f 资源组ui修改 2026-05-20 14:31:45 +08:00
zhx
35d9c1a9d1 转盘抽奖 2026-05-20 13:53:14 +08:00
zhx
6375b45f3a 转盘 2026-05-20 12:01:58 +08:00
zhx
045a8af46f 多选修改 2026-05-19 22:23:14 +08:00
zhx
aa0988af6b 修复时间字段 2026-05-19 21:04:46 +08:00
zhx
8d0836e7e8 支持礼物权重单独编辑 2026-05-19 20:23:23 +08:00
zhx
3a96357b2a 上传限制 2026-05-19 19:39:29 +08:00
zhx
9d3880a8aa 修改查询类型 2026-05-19 18:30:33 +08:00
zhx
ec97291d0e 修改并发上传 2026-05-19 18:25:02 +08:00
zhx
d2a994cfa9 增加Ip查询 2026-05-19 18:00:44 +08:00
zhx
027ff95e8b 更新自动发放 2026-05-19 15:36:32 +08:00
23 changed files with 4070 additions and 940 deletions

View File

@ -1,20 +0,0 @@
## 安装使用
2. 安装依赖
```bash
npm i -g corepack
pnpm install
```
3. 运行
```bash
pnpm dev
```
4. 打包
```bash
pnpm build
```

View File

@ -33,6 +33,10 @@ export async function updateGift(data: Record<string, any>) {
return requestClient.put('/sys/gift/config', data);
}
export async function updateGiftSort(data: Record<string, any>) {
return requestClient.put('/sys/gift/config/sort', data);
}
export async function addGift(data: Record<string, any>) {
return requestClient.post('/sys/gift/config', data);
}

View File

@ -37,6 +37,12 @@ export async function pageBank(params: Record<string, any>) {
});
}
export async function getBankStatistics(params: Record<string, any>) {
return requestClient.get<Record<string, any>>('/user-bank-balance/statistics', {
params,
});
}
export async function exprotBank(params: Record<string, any>) {
return downloadLegacyExcel(
'/user-bank-balance/export',
@ -132,6 +138,22 @@ export async function pageSalary(params: Record<string, any>) {
);
}
export async function pageManualSalary(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/team/salary/manual/page',
{
params,
},
);
}
export async function confirmManualSalary(data: Record<string, any>) {
return requestClient.post<Array<Record<string, any>>>(
'/team/salary/manual/pay',
data,
);
}
export async function pageCpApply(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/user-cp-apply/page',

View File

@ -125,6 +125,12 @@ export async function pageResidentWheelDrawRecords(params: Record<string, any>)
});
}
export async function retryResidentWheelDrawRecord(id: number | string) {
return requestClient.post<Record<string, any>>(`${WHEEL_BASE}/record/retry`, {
id,
});
}
export async function getResidentSmashGoldenEggConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>(`${SMASH_GOLDEN_EGG_BASE}/config`, {
params: { sysOrigin },

View File

@ -1,19 +1,13 @@
<script lang="ts" setup>
import { computed, h, useAttrs } from 'vue';
import { computed, watch } from 'vue';
import { useAccessStore } from '@vben/stores';
import { Select } from 'antdv-next';
import {
SYS_ORIGIN_OPTIONS,
getDisplayableSysOriginOptions,
getDisplayableSysOrigins,
isHiddenSysOrigin,
} from '#/views/system/shared';
import SysOriginIcon from './sys-origin-icon.vue';
defineOptions({
inheritAttrs: false,
name: 'SysOriginSelect',
@ -38,129 +32,43 @@ const emit = defineEmits<{
(event: 'update:value', value: SelectValue): void;
}>();
const attrs = useAttrs();
const accessStore = useAccessStore();
const fallbackOptions = computed(() => {
const options = getDisplayableSysOrigins(accessStore.accessCodes || []);
return (options.length > 0 ? options : getDisplayableSysOriginOptions(SYS_ORIGIN_OPTIONS)) as AppOriginOption[];
return (options.length > 0 ? options : SYS_ORIGIN_OPTIONS) as AppOriginOption[];
});
const normalizedOptions = computed(() =>
getDisplayableSysOriginOptions(props.options?.length ? props.options : fallbackOptions.value).map(
(item) => {
const value = String(item.value ?? '');
const label = String(item.label ?? ('name' in item ? item.name : '') ?? value);
return {
...item,
icon: String(item.icon || value).toLowerCase(),
label: label || value,
value,
};
},
),
const rawOptions = computed(() =>
props.options?.length ? props.options : fallbackOptions.value,
);
const optionMap = computed(
() => new Map(normalizedOptions.value.map((item) => [String(item.value), item])),
const hiddenDefaultValue = computed(() =>
String(rawOptions.value[0]?.value ?? SYS_ORIGIN_OPTIONS[0]?.value ?? 'LIKEI'),
);
const displayValue = computed<SelectValue>(() => {
if (Array.isArray(props.value)) {
return props.value.filter((item) => !isHiddenSysOrigin(item));
}
return isHiddenSysOrigin(props.value) ? undefined : props.value;
});
const selectAttrs = computed(() => {
const nextAttrs = { ...attrs } as Record<string, any>;
delete nextAttrs.options;
delete nextAttrs.value;
delete nextAttrs.onChange;
delete nextAttrs['onUpdate:value'];
delete nextAttrs.labelRender;
delete nextAttrs.optionRender;
delete nextAttrs.optionLabelProp;
delete nextAttrs.optionFilterProp;
return nextAttrs;
});
function renderContent(option?: Record<string, any> | null) {
if (!option || isHiddenSysOrigin(option.value ?? option.label ?? option.name)) {
return null;
}
const label = String(option.label || option.name || option.value || '-');
const code = String(option.icon || option.value || label);
return h('span', { class: 'sys-origin-select__content' }, [
h(SysOriginIcon, {
code,
label,
size: 18,
}),
h('span', { class: 'sys-origin-select__text' }, label),
]);
}
function resolveOption(option?: Record<string, any> | null): null | Record<string, any> {
if (!option) {
return null;
}
const nextOption = option.option || option.data || option.originOption || option.item;
if (nextOption && nextOption !== option) {
return resolveOption(nextOption);
}
if (option.value !== undefined) {
return optionMap.value.get(String(option.value)) || option;
}
return option;
}
function renderOption(option: Record<string, any>) {
return renderContent(resolveOption(option));
}
function renderLabel(item: Record<string, any>) {
if (isHiddenSysOrigin(item?.value ?? item?.label)) {
return null;
}
return renderContent(
resolveOption(item) || {
icon: item?.value,
label: item?.label || item?.value || '',
value: item?.value || '',
},
);
}
function handleChange(value: SelectValue, option: any) {
emit('update:value', value);
emit('change', value, option);
}
watch(
() => [props.value, hiddenDefaultValue.value] as const,
([value, defaultValue]) => {
if (Array.isArray(value) || value !== undefined && value !== null && value !== '') {
return;
}
if (!defaultValue) {
return;
}
emit('update:value', defaultValue);
emit('change', defaultValue, rawOptions.value[0] || null);
},
{ immediate: true },
);
</script>
<template>
<Select
v-bind="selectAttrs"
:value="displayValue"
:options="normalizedOptions"
option-filter-prop="label"
option-label-prop="label"
:label-render="renderLabel"
:option-render="renderOption"
@change="handleChange"
/>
<span class="sys-origin-select--hidden" aria-hidden="true" />
</template>
<style scoped>
.sys-origin-select__content {
align-items: center;
display: inline-flex;
gap: 8px;
min-width: 0;
}
.sys-origin-select__text {
line-height: 1;
white-space: nowrap;
.sys-origin-select--hidden {
display: none !important;
}
</style>

View File

@ -25,6 +25,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/operate/auto-salary-pay-record.vue'),
meta: { title: '自动发放工资凭据' },
},
{
name: 'ManualSalaryPayment',
path: 'manual-salary-payment',
alias: '/manual-salary-payment',
component: () => import('#/views/operate/manual-salary-payment.vue'),
meta: { title: '工资发放' },
},
{
name: 'UserBankWithdrawMoneyApply',
path: 'withdraw-money-apply-page',

View File

@ -1,5 +1,13 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import {
computed,
defineComponent,
nextTick,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
} from 'vue';
import { Pagination, Table } from 'antdv-next';
@ -11,32 +19,59 @@ const props = withDefaults(
columns: Array<Record<string, any>>;
current: number;
dataSource: Array<Record<string, any>>;
hasMore?: boolean;
infinite?: boolean;
loading?: boolean;
loadMoreText?: string;
pageSize: number;
plain?: boolean;
rowKey?: string;
rowSelection?: Record<string, any>;
scrollX?: number | string;
showPager?: boolean;
showSizeChanger?: boolean;
summaryText?: string;
total: number;
}>(),
{
autoHeight: false,
hasMore: false,
infinite: false,
loadMoreText: '下滑加载下一页',
loading: false,
plain: false,
rowKey: 'id',
scrollX: 1000,
showPager: true,
showSizeChanger: true,
},
);
const emit = defineEmits<{
loadMore: [];
pageChange: [page: number, pageSize: number];
}>();
const RenderNode = defineComponent({
name: 'AdminConfigTableRenderNode',
props: {
node: {
default: null,
required: false,
},
},
setup(props) {
return () => props.node as any;
},
});
const tableAreaRef = ref<HTMLDivElement | null>(null);
const pagerRef = ref<HTMLDivElement | null>(null);
const summaryRef = ref<HTMLDivElement | null>(null);
const tableScrollY = ref(320);
let tableResizeObserver: null | ResizeObserver = null;
let tableBodyElement: HTMLElement | null = null;
let loadMoreLocked = false;
const tableScroll = computed(() => {
const scroll: Record<string, any> = { x: props.scrollX };
@ -46,6 +81,19 @@ const tableScroll = computed(() => {
return scroll;
});
const infiniteStatusText = computed(() => {
if (!props.infinite) {
return '';
}
if (props.loading && props.dataSource.length > 0) {
return '加载中...';
}
if (props.hasMore) {
return props.loadMoreText;
}
return props.dataSource.length > 0 ? '已加载全部' : '';
});
function stringifyCellValue(value: any) {
if (value === undefined || value === null || value === '') {
return '-';
@ -68,6 +116,10 @@ function getColumnValue(record: Record<string, any>, column: Record<string, any>
return record?.[String(dataIndex)];
}
function isVNodeLike(value: any) {
return Boolean(value && typeof value === 'object' && value.__v_isVNode);
}
function updateTableScrollY() {
if (!props.autoHeight) {
return;
@ -85,9 +137,45 @@ function updateTableScrollY() {
);
}
function emitLoadMore() {
if (loadMoreLocked) {
return;
}
loadMoreLocked = true;
emit('loadMore');
window.setTimeout(() => {
loadMoreLocked = false;
}, 300);
}
function handleTableBodyScroll(event: Event) {
if (!props.infinite || props.loading || !props.hasMore) {
return;
}
const target = event.currentTarget as HTMLElement;
const distanceToBottom =
target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceToBottom <= 80) {
emitLoadMore();
}
}
function bindTableBodyScroll() {
const nextBody = props.infinite
? (tableAreaRef.value?.querySelector('.ant-table-body') as HTMLElement | null)
: null;
if (nextBody === tableBodyElement) {
return;
}
tableBodyElement?.removeEventListener('scroll', handleTableBodyScroll);
tableBodyElement = nextBody;
tableBodyElement?.addEventListener('scroll', handleTableBodyScroll);
}
async function refreshTableScroll() {
await nextTick();
updateTableScrollY();
bindTableBodyScroll();
}
onMounted(async () => {
@ -104,10 +192,18 @@ onMounted(async () => {
if (pagerRef.value) {
tableResizeObserver.observe(pagerRef.value);
}
if (summaryRef.value) {
tableResizeObserver.observe(summaryRef.value);
}
window.addEventListener('resize', updateTableScrollY);
});
onUpdated(() => {
void refreshTableScroll();
});
onBeforeUnmount(() => {
tableBodyElement?.removeEventListener('scroll', handleTableBodyScroll);
tableResizeObserver?.disconnect();
window.removeEventListener('resize', updateTableScrollY);
});
@ -118,6 +214,10 @@ onBeforeUnmount(() => {
class="admin-config-table-card"
:class="{ 'admin-config-table-card--plain': plain }"
>
<div v-if="summaryText" ref="summaryRef" class="admin-config-table-summary">
{{ summaryText }}
</div>
<div ref="tableAreaRef" class="admin-config-table-area">
<Table
class="admin-config-table"
@ -126,22 +226,32 @@ onBeforeUnmount(() => {
:loading="loading"
:pagination="false"
:row-key="rowKey"
:row-selection="rowSelection"
:scroll="tableScroll"
>
<template #headerCell="slotProps">
<slot name="headerCell" v-bind="slotProps">
<RenderNode
v-if="isVNodeLike(slotProps.column?.title)"
:node="slotProps.column.title"
/>
<slot v-else name="headerCell" v-bind="slotProps">
{{ slotProps.column.title }}
</slot>
</template>
<template #bodyCell="slotProps">
<slot name="bodyCell" v-bind="slotProps">
<RenderNode v-if="isVNodeLike(slotProps.text)" :node="slotProps.text" />
<slot v-else name="bodyCell" v-bind="slotProps">
{{ stringifyCellValue(getColumnValue(slotProps.record, slotProps.column)) }}
</slot>
</template>
</Table>
</div>
<div ref="pagerRef" class="admin-config-table-pager">
<div v-if="infiniteStatusText" class="admin-config-table-load-status">
{{ infiniteStatusText }}
</div>
<div v-if="showPager" ref="pagerRef" class="admin-config-table-pager">
<Pagination
:current="current"
:page-size="pageSize"
@ -178,6 +288,15 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.admin-config-table-summary {
border-bottom: 1px solid rgb(237 240 244);
color: rgb(71 84 103);
flex: 0 0 auto;
font-size: 12px;
line-height: 18px;
padding: 8px 12px;
}
.admin-config-table {
height: 100%;
width: 100%;
@ -258,6 +377,16 @@ onBeforeUnmount(() => {
background: rgb(250 252 255);
}
.admin-config-table-load-status {
border-top: 1px solid rgb(237 240 244);
color: rgb(100 116 139);
flex: 0 0 auto;
font-size: 12px;
line-height: 18px;
padding: 8px 12px;
text-align: center;
}
.admin-config-table-pager {
border-top: 1px solid rgb(237 240 244);
display: flex;

View File

@ -38,6 +38,7 @@ const accountHandleOpen = ref(false);
const query = reactive({
cursor: 1,
deviceId: '',
ip: '',
limit: 20,
userId: '',
});
@ -161,6 +162,14 @@ loadData(true);
style="width: 260px"
/>
</InlineFilterField>
<InlineFilterField label="IP">
<Input
v-model:value="query.ip"
allow-clear
placeholder="IP"
style="width: 200px"
/>
</InlineFilterField>
<Button :loading="loading" type="primary" @click="loadData(true)">
搜索
</Button>

View File

@ -21,7 +21,11 @@ import { getAccessImgUrl, simpleUploadFile } from '#/api/legacy/oss';
import { regionConfigTable } from '#/api/legacy/system';
import { normalizeResourceCode } from '#/views/_shared/resource-code';
import { GIFT_CONFIG_TAB_OPTIONS } from '../constants';
import {
CP_RELATION_TYPE_OPTIONS,
GIFT_CONFIG_TAB_OPTIONS,
GIFT_SPECIAL_OPTIONS,
} from '../constants';
type AssetKind = 'cover' | 'source';
type RowStatus = 'error' | 'ready' | 'saved' | 'saving' | 'uploading';
@ -54,6 +58,13 @@ interface ParsedGiftFile {
name: string;
}
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchGiftRow;
}
const props = defineProps<{
initialSysOrigin?: number | string;
open: boolean;
@ -65,9 +76,13 @@ const emit = defineEmits<{
success: [];
}>();
const DEFAULT_GIFT_SPECIAL_LIST = ['MUSIC', 'ANIMATION'];
const batchForm = reactive({
cpRelationType: 'CP',
giftTab: 'ORDINARY',
regionList: [] as Array<number | string>,
specialList: [...DEFAULT_GIFT_SPECIAL_LIST],
sysOrigin: '',
});
@ -82,11 +97,25 @@ const sequence = ref(0);
const showOnlyErrors = ref(false);
const submitting = ref(false);
const droppedFilePathMap = new WeakMap<File, string>();
const uploadQueue: UploadTask[] = [];
const MAX_UPLOAD_CONCURRENCY = 1;
let activeUploadCount = 0;
const giftTabOptions = GIFT_CONFIG_TAB_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const cpRelationTypeOptions = CP_RELATION_TYPE_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const defaultSpecialText = computed(() =>
GIFT_SPECIAL_OPTIONS.filter((item) =>
DEFAULT_GIFT_SPECIAL_LIST.includes(item.value),
)
.map((item) => item.name)
.join(' + '),
);
const regionOptions = computed(() =>
regions.value.map((item) => ({
label: String(item.regionName || item.name || item.id || '-'),
@ -161,16 +190,28 @@ const rowSelection = computed(() => ({
},
}));
watch(
() => batchForm.giftTab,
(giftTab) => {
if (giftTab === 'CP' && !batchForm.cpRelationType) {
batchForm.cpRelationType = 'CP';
}
},
);
function resetBatch() {
batchForm.sysOrigin =
String(props.initialSysOrigin || props.sysOriginOptions[0]?.value || 'LIKEI');
batchForm.giftTab = 'ORDINARY';
batchForm.cpRelationType = 'CP';
batchForm.regionList = [];
batchForm.specialList = [...DEFAULT_GIFT_SPECIAL_LIST];
rows.value = [];
selectedRowKeys.value = [];
activeRowKey.value = '';
showOnlyErrors.value = false;
sequence.value = 0;
uploadQueue.length = 0;
}
watch(
@ -342,7 +383,26 @@ function setRowAsset(row: BatchGiftRow, kind: AssetKind, file: File) {
}
row.saved = false;
refreshAllRows();
void uploadAsset(row, kind, file, fileName);
enqueueUpload({ file, fileName, kind, row });
}
function enqueueUpload(task: UploadTask) {
uploadQueue.push(task);
runUploadQueue();
}
function runUploadQueue() {
while (activeUploadCount < MAX_UPLOAD_CONCURRENCY && uploadQueue.length > 0) {
const task = uploadQueue.shift();
if (!task) {
return;
}
activeUploadCount += 1;
void uploadAsset(task.row, task.kind, task.file, task.fileName).finally(() => {
activeUploadCount -= 1;
runUploadQueue();
});
}
}
async function uploadAsset(
@ -628,6 +688,7 @@ function customRow(record: Record<string, any>) {
function buildPayload(row: BatchGiftRow) {
return {
account: '',
cpRelationType: batchForm.giftTab === 'CP' ? batchForm.cpRelationType : '',
explanationGift: false,
expiredTime: '',
giftCandy: Number(row.giftCandy || 0),
@ -639,7 +700,7 @@ function buildPayload(row: BatchGiftRow) {
giftTab: batchForm.giftTab,
regionList: [...batchForm.regionList],
sort: Number(row.sort || 0),
specialList: [],
specialList: [...batchForm.specialList],
standardId: '',
sysOrigin: batchForm.sysOrigin,
type: 'GOLD',
@ -719,6 +780,14 @@ async function submitBatch() {
:options="giftTabOptions"
/>
</div>
<div v-if="batchForm.giftTab === 'CP'" class="common-field">
<span class="common-label">CP礼物类型</span>
<Select
v-model:value="batchForm.cpRelationType"
option-label-prop="label"
:options="cpRelationTypeOptions"
/>
</div>
<div class="common-field common-field--wide">
<span class="common-label">区域</span>
<Select
@ -729,6 +798,7 @@ async function submitBatch() {
/>
</div>
<div class="common-meta">收费类型金币</div>
<div class="common-meta">特效{{ defaultSpecialText }}</div>
</div>
<div class="batch-workspace">
@ -1131,6 +1201,18 @@ async function submitBatch() {
width: 52px;
}
.asset-cover {
border-radius: 6px;
display: block;
overflow: hidden;
}
.asset-cover :deep(.ant-image-img) {
height: 52px;
object-fit: contain;
width: 52px;
}
.asset-empty {
align-items: center;
background: #f8fafc;

View File

@ -1,21 +1,6 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import {
addGift,
updateGift,
} from '#/api/legacy/gift';
import {
mapLuckyGiftStandard,
} from '#/api/legacy/game-lucky-gift';
import {
getAccessImgUrl,
simpleUploadFile,
} from '#/api/legacy/oss';
import {
regionConfigTable,
} from '#/api/legacy/system';
import {
Button,
DatePicker,
@ -24,21 +9,47 @@ import {
FormItem,
Image,
Input,
message,
Select,
Space,
Spin,
Switch,
TextArea,
message,
} from 'antdv-next';
import {
mapLuckyGiftStandard,
} from '#/api/legacy/game-lucky-gift';
import {
addGift,
updateGift,
} from '#/api/legacy/gift';
import {
getAccessImgUrl,
simpleUploadFile,
} from '#/api/legacy/oss';
import {
regionConfigTable,
} from '#/api/legacy/system';
import {
CP_RELATION_TYPE_OPTIONS,
GIFT_CONFIG_TAB_OPTIONS,
GIFT_SPECIAL_OPTIONS,
} from '../constants';
defineOptions({ name: 'OperateGiftFormModal' });
const props = defineProps<{
open: boolean;
row?: null | Record<string, any>;
sysOrigin: string;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
function createForm(sysOrigin = '') {
return {
account: '',
@ -52,6 +63,7 @@ function createForm(sysOrigin = '') {
giftSourceUrl: '',
giftTab: 'ORDINARY',
id: '',
cpRelationType: 'CP',
regionList: [] as Array<number | string>,
sort: '',
specialList: [] as string[],
@ -61,17 +73,6 @@ function createForm(sysOrigin = '') {
};
}
const props = defineProps<{
open: boolean;
row?: null | Record<string, any>;
sysOrigin: string;
}>();
const emit = defineEmits<{
close: [];
success: [];
}>();
const form = reactive(createForm());
const loading = ref(false);
const saving = ref(false);
@ -96,6 +97,10 @@ const giftTabOptions = GIFT_CONFIG_TAB_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const cpRelationTypeOptions = CP_RELATION_TYPE_OPTIONS.map((item) => ({
label: item.name,
value: item.value as any,
}));
const chargeTypeOptions = [
{ label: '金币', value: 'GOLD' },
{ label: '钻石', value: 'DIAMOND' },
@ -128,6 +133,13 @@ function normalizeMultiValue(value: any) {
.filter(Boolean);
}
function normalizeTimestampValue(value: any) {
if (value === '' || value === null || value === undefined) {
return '';
}
return String(value);
}
function resetForm() {
Object.assign(form, createForm(props.sysOrigin));
previousStandardId.value = '';
@ -157,6 +169,8 @@ watch(
Object.assign(form, {
...props.row,
explanationGift: Boolean(props.row.explanationGift),
expiredTime: normalizeTimestampValue(props.row.expiredTime),
cpRelationType: props.row.cpRelationType || 'CP',
regionList: normalizeMultiValue(props.row.regions || props.row.regionList),
specialList: normalizeMultiValue(props.row.special || props.row.specialList),
sysOrigin: props.row.sysOrigin || props.sysOrigin,
@ -179,6 +193,15 @@ watch(
},
);
watch(
() => form.giftTab,
(giftTab) => {
if (giftTab === 'CP' && !form.cpRelationType) {
form.cpRelationType = 'CP';
}
},
);
function toggleAllRegions() {
if (form.regionList.length === regions.value.length) {
form.regionList = [];
@ -266,6 +289,10 @@ async function handleSubmit() {
message.warning('请输入归属人账号');
return;
}
if (form.giftTab === 'CP' && !form.cpRelationType) {
message.warning('请选择CP礼物类型');
return;
}
if (isLuckyOrMagic.value && !form.standardId) {
message.warning('幸运/魔法礼物必须选择规格');
return;
@ -287,14 +314,11 @@ async function handleSubmit() {
try {
const payload = {
...form,
cpRelationType: form.giftTab === 'CP' ? form.cpRelationType : '',
giftCandy: form.type === 'FREE' ? '' : form.giftCandy,
giftIntegral: form.type === 'FREE' ? '' : form.giftIntegral,
};
if (payload.id) {
await updateGift(payload);
} else {
await addGift(payload);
}
await (payload.id ? updateGift(payload) : addGift(payload));
message.success('保存成功');
emit('success');
emit('close');
@ -309,150 +333,165 @@ async function handleSubmit() {
:open="open"
:title="title"
destroy-on-close
width="980"
root-class="gift-form-drawer-root"
width="1120"
:styles="{
body: { padding: '0', overflow: 'hidden' },
footer: { padding: '10px 16px' },
}"
@close="emit('close')"
>
<Spin :spinning="loading">
<Form layout="vertical">
<div class="grid">
<FormItem label="平台">
<Input :value="form.sysOrigin" disabled />
</FormItem>
<FormItem label="区域">
<Space direction="vertical" style="width: 100%">
<Button size="small" @click="toggleAllRegions">
{{ form.regionList.length === regions.length ? '取消全选' : '全选' }}
<Spin :spinning="loading" wrapper-class-name="gift-editor-spin">
<div class="gift-editor">
<aside class="media-panel">
<div class="media-block">
<div class="media-head">
<span>封面</span>
<span class="required-mark">必填</span>
</div>
<Image
v-if="previewCover"
:src="previewCover"
class="preview-image"
/>
<div v-else class="preview-image preview-image--empty">未上传</div>
<div class="media-actions">
<Button :loading="coverUploading" size="small" @click="openCoverUpload">
上传封面
</Button>
<Button
v-if="form.giftPhoto"
danger
size="small"
@click="form.giftPhoto = ''"
>
清空
</Button>
<Select option-label-prop="label"
v-model:value="form.regionList"
:options="regionOptions"
mode="multiple"
placeholder="请选择区域"
show-search
style="width: 100%"
/>
</Space>
</FormItem>
</div>
<div class="grid">
<FormItem label="名称">
<Input v-model:value="form.giftName" />
</FormItem>
<FormItem label="Code">
<Input v-model:value="form.giftCode" />
</FormItem>
</div>
<div class="grid">
<FormItem label="金币">
<Input v-model:value="form.giftCandy" :disabled="form.type === 'FREE'" />
</FormItem>
<FormItem label="积分">
<Input v-model:value="form.giftIntegral" :disabled="form.type === 'FREE'" />
</FormItem>
</div>
<div class="grid">
<FormItem label="礼物类型">
<Select
v-model:value="form.giftTab"
:options="giftTabOptions"
option-label-prop="label"
placeholder="请选择礼物类型"
/>
</FormItem>
<FormItem label="收费类型">
<Select
v-model:value="form.type"
:options="chargeTypeOptions"
option-label-prop="label"
placeholder="请选择收费类型"
/>
</FormItem>
</div>
<div class="grid">
<FormItem label="特效">
<Select
v-model:value="form.specialList"
:options="specialOptions"
mode="multiple"
option-label-prop="label"
placeholder="请选择特效"
/>
</FormItem>
<FormItem label="排序">
<Input v-model:value="form.sort" />
</FormItem>
</div>
<div class="grid">
<FormItem label="封面">
<div class="upload-field">
<Image
v-if="previewCover"
:src="previewCover"
class="preview-image"
/>
<div v-else class="preview-image preview-image--empty">未上传</div>
<Space>
<Button :loading="coverUploading" @click="openCoverUpload">上传封面</Button>
<Button v-if="form.giftPhoto" danger @click="form.giftPhoto = ''">清空</Button>
</Space>
</div>
</FormItem>
<FormItem label="资源">
<div class="upload-field">
<div class="source-value">{{ previewSource || '未上传' }}</div>
<Space>
<Button :loading="sourceUploading" @click="openSourceUpload">上传资源</Button>
<Button
v-if="form.giftSourceUrl"
danger
@click="form.giftSourceUrl = ''"
>
清空
</Button>
</Space>
</div>
<div class="media-block">
<div class="media-head">
<span>资源</span>
<span class="optional-mark">可选</span>
</div>
</FormItem>
</div>
<div class="source-value">{{ previewSource || '未上传' }}</div>
<div class="media-actions">
<Button :loading="sourceUploading" size="small" @click="openSourceUpload">
上传资源
</Button>
<Button
v-if="form.giftSourceUrl"
danger
size="small"
@click="form.giftSourceUrl = ''"
>
清空
</Button>
</div>
</div>
</aside>
<div class="grid">
<FormItem label="有效期">
<DatePicker
v-model:value="form.expiredTime"
style="width: 100%"
value-format="x"
/>
</FormItem>
<FormItem v-if="form.giftTab === 'EXCLUSIVE'" label="归属人账号">
<Input v-model:value="form.account" />
</FormItem>
</div>
<main class="editor-panel">
<Form class="editor-form" layout="vertical">
<div class="section-title">基础信息</div>
<div class="form-grid">
<FormItem label="礼物类型">
<Select
v-model:value="form.giftTab"
:options="giftTabOptions"
option-label-prop="label"
placeholder="请选择礼物类型"
/>
</FormItem>
<FormItem label="收费类型">
<Select
v-model:value="form.type"
:options="chargeTypeOptions"
option-label-prop="label"
placeholder="请选择收费类型"
/>
</FormItem>
<FormItem label="排序">
<Input v-model:value="form.sort" />
</FormItem>
<FormItem class="span-2" label="名称">
<Input v-model:value="form.giftName" />
</FormItem>
<FormItem label="Code">
<Input v-model:value="form.giftCode" />
</FormItem>
<FormItem label="有效期">
<DatePicker
v-model:value="form.expiredTime"
style="width: 100%"
value-format="x"
/>
</FormItem>
<FormItem label="金币">
<Input v-model:value="form.giftCandy" :disabled="form.type === 'FREE'" />
</FormItem>
<FormItem label="积分">
<Input v-model:value="form.giftIntegral" :disabled="form.type === 'FREE'" />
</FormItem>
</div>
<div class="grid" v-if="isLuckyOrMagic">
<FormItem label="幸运规格">
<Select
v-model:value="form.standardId"
:options="standardSelectOptions"
option-label-prop="label"
placeholder="请选择幸运规格"
/>
</FormItem>
</div>
<div class="grid" v-if="form.giftTab === 'CP'">
<FormItem label="是否告白礼物">
<Switch v-model:checked="form.explanationGift" />
</FormItem>
</div>
<FormItem label="备注">
<TextArea :value="previewSource" :rows="2" disabled />
</FormItem>
</Form>
<div class="section-title section-title--spaced">范围与效果</div>
<div class="form-grid">
<FormItem class="span-3" label="区域">
<div class="inline-field">
<Select
v-model:value="form.regionList"
:max-tag-count="4"
:options="regionOptions"
class="compact-multiple-select"
mode="multiple"
option-label-prop="label"
placeholder="请选择区域"
show-search
/>
<Button size="small" @click="toggleAllRegions">
{{ form.regionList.length === regions.length ? '取消全选' : '全选' }}
</Button>
</div>
</FormItem>
<FormItem class="span-3" label="特效">
<Select
v-model:value="form.specialList"
:max-tag-count="4"
:options="specialOptions"
class="compact-multiple-select"
mode="multiple"
option-label-prop="label"
placeholder="请选择特效"
/>
</FormItem>
<FormItem v-if="form.giftTab === 'EXCLUSIVE'" label="归属人账号">
<Input v-model:value="form.account" />
</FormItem>
<FormItem v-if="isLuckyOrMagic" label="幸运规格">
<Select
v-model:value="form.standardId"
:options="standardSelectOptions"
option-label-prop="label"
placeholder="请选择幸运规格"
/>
</FormItem>
<FormItem v-if="form.giftTab === 'CP'" label="CP礼物类型">
<Select
v-model:value="form.cpRelationType"
:options="cpRelationTypeOptions"
option-label-prop="label"
placeholder="请选择CP礼物类型"
/>
</FormItem>
<FormItem v-if="form.giftTab === 'CP'" label="告白礼物">
<Switch v-model:checked="form.explanationGift" />
</FormItem>
</div>
</Form>
</main>
</div>
</Spin>
<input
@ -461,17 +500,17 @@ async function handleSubmit() {
hidden
type="file"
@change="handleCoverUpload"
>
/>
<input
ref="sourceInputRef"
accept=".svga,.pag,.mp4"
hidden
type="file"
@change="handleSourceUpload"
>
/>
<template #footer>
<Space>
<Space class="drawer-footer-actions">
<Button @click="emit('close')">取消</Button>
<Button :loading="saving" type="primary" @click="handleSubmit">
保存
@ -482,22 +521,72 @@ async function handleSubmit() {
</template>
<style scoped>
.grid {
.gift-editor {
background: #f6f8fb;
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
grid-template-columns: 260px minmax(0, 1fr);
height: calc(100vh - 106px);
min-height: 0;
padding: 12px;
}
.upload-field {
.media-panel,
.editor-panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
min-height: 0;
}
.media-panel {
display: grid;
gap: 10px;
align-content: start;
gap: 12px;
overflow: auto;
padding: 12px;
}
.media-block {
display: grid;
gap: 8px;
}
.media-head {
align-items: center;
color: #111827;
display: flex;
font-size: 13px;
font-weight: 600;
justify-content: space-between;
}
.required-mark,
.optional-mark {
border-radius: 999px;
font-size: 12px;
font-weight: 400;
line-height: 18px;
padding: 0 8px;
}
.required-mark {
background: #fff1f2;
color: #e11d48;
}
.optional-mark {
background: #f1f5f9;
color: #64748b;
}
.preview-image {
border-radius: 16px;
height: 120px;
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 8px;
height: 180px;
object-fit: cover;
width: 120px;
width: 100%;
}
.preview-image--empty,
@ -505,17 +594,104 @@ async function handleSubmit() {
align-items: center;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 12px;
border-radius: 8px;
color: #94a3b8;
display: flex;
font-size: 12px;
min-height: 72px;
padding: 12px;
padding: 10px;
word-break: break-all;
}
.media-actions,
.drawer-footer-actions {
display: flex;
justify-content: flex-end;
}
.editor-panel {
overflow: auto;
padding: 14px 16px;
}
.editor-form :deep(.ant-form-item) {
margin-bottom: 10px;
}
.editor-form :deep(.ant-form-item-label) {
padding-bottom: 4px;
}
.editor-form :deep(.ant-form-item-label > label) {
color: #475569;
font-size: 12px;
height: 18px;
}
.editor-form :deep(.ant-input),
.editor-form :deep(.ant-picker),
.editor-form :deep(.ant-select-selector) {
border-radius: 6px;
}
.section-title {
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 20px;
margin-bottom: 8px;
}
.section-title--spaced {
border-top: 1px solid #eef2f7;
margin-top: 4px;
padding-top: 12px;
}
.form-grid {
display: grid;
column-gap: 12px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.span-2 {
grid-column: span 2;
}
.span-3 {
grid-column: 1 / -1;
}
.inline-field {
align-items: center;
display: grid;
gap: 8px;
grid-template-columns: minmax(0, 1fr) auto;
}
.compact-multiple-select :deep(.ant-select-selection-overflow) {
flex-wrap: nowrap;
overflow: hidden;
}
.compact-multiple-select :deep(.ant-select-selection-item) {
max-width: 140px;
}
@media (max-width: 900px) {
.grid {
.gift-editor {
grid-template-columns: minmax(0, 1fr);
height: auto;
overflow: auto;
}
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
.span-2,
.span-3 {
grid-column: auto;
}
}
</style>

View File

@ -20,14 +20,21 @@ import {
defineOptions({ name: 'OperatePropsSourceSelectDrawer' });
type SourceTypeOption = {
label: string;
value: string;
};
const props = withDefaults(
defineProps<{
defaultType?: string;
open: boolean;
sysOrigin: string;
typeOptions?: SourceTypeOption[];
}>(),
{
defaultType: 'AVATAR_FRAME',
typeOptions: undefined,
},
);
@ -36,11 +43,6 @@ const emit = defineEmits<{
select: [item: SourceItem];
}>();
type SourceTypeOption = {
label: string;
value: string;
};
type SourceItem = {
amount?: number | string;
cover?: string;
@ -62,12 +64,21 @@ const TYPE_OPTIONS: SourceTypeOption[] = [
{ label: '聊天气泡', value: 'CHAT_BUBBLE' },
{ label: '飘窗', value: 'FLOAT_PICTURE' },
{ label: '碎片', value: 'FRAGMENTS' },
{ label: '资料卡', value: 'DATA_CARD' },
];
const typeSelectOptions = TYPE_OPTIONS.map((item) => ({
label: item.label,
value: item.value as any,
}));
const effectiveTypeOptions = computed(() =>
(props.typeOptions && props.typeOptions.length > 0 ? props.typeOptions : TYPE_OPTIONS).map(
(item) => ({
label: item.label,
value: item.value as any,
}),
),
);
const allowedTypeSet = computed(
() => new Set(effectiveTypeOptions.value.map((item) => String(item.value))),
);
const loading = ref(false);
const keyword = ref('');
@ -197,13 +208,21 @@ function formatSourcePrice(amount: number | string | undefined) {
return `${amount} 金币`;
}
function resolveSourceType(type?: string) {
const value = String(type || '').trim();
if (value && allowedTypeSet.value.has(value)) {
return value;
}
return String(effectiveTypeOptions.value[0]?.value || 'AVATAR_FRAME');
}
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
query.propsType = props.defaultType || 'AVATAR_FRAME';
query.propsType = resolveSourceType(props.defaultType);
keyword.value = '';
void loadSource(query.propsType);
},
@ -213,14 +232,26 @@ watch(
watch(
() => props.defaultType,
(value) => {
if (!props.open || !value) {
if (!props.open) {
return;
}
query.propsType = value;
void loadSource(value);
query.propsType = resolveSourceType(value);
void loadSource(query.propsType);
},
);
watch(
() => props.typeOptions,
() => {
if (!props.open) {
return;
}
query.propsType = resolveSourceType(query.propsType || props.defaultType);
void loadSource(query.propsType);
},
{ deep: true },
);
watch(
() => props.sysOrigin,
() => {
@ -327,7 +358,7 @@ function handleSelect(item: SourceItem) {
<Space class="toolbar" wrap>
<Select
v-model:value="query.propsType"
:options="typeSelectOptions"
:options="effectiveTypeOptions"
option-label-prop="label"
placeholder="请选择道具类型"
style="width: 220px"

View File

@ -105,6 +105,12 @@ export const GIFT_CONFIG_TAB_OPTIONS = [
{ value: 'CUSTOMIZED', name: '定制礼物' },
];
export const CP_RELATION_TYPE_OPTIONS = [
{ value: 'CP', name: 'CP' },
{ value: 'BROTHER', name: '兄弟' },
{ value: 'SISTERS', name: '姐妹' },
];
export const GIFT_SPECIAL_OPTIONS = [
{ value: 'ANIMATION', name: '动画' },
{ value: 'MUSIC', name: '音乐' },

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,643 @@
<script lang="ts" setup>
import {
computed,
reactive,
ref,
watch,
} from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { getCountryAlls } from '#/api/legacy/system';
import {
confirmManualSalary,
pageManualSalary,
} from '#/api/legacy/operate';
import AccountInput from '#/components/account-input.vue';
import AdminConfigTable from '#/views/_shared/admin-config-table.vue';
import { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Modal,
Select,
Space,
Table,
Tag,
message,
} from 'antdv-next';
import UserProfileLink from './components/user-profile-link.vue';
defineOptions({ name: 'OperateManualSalaryPayment' });
type SalarySortKey =
| 'currentSalaryBalance'
| 'payableSalary'
| 'totalIssuedSalary';
type SalarySortOrder = '' | 'ascend' | 'descend';
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const loading = ref(false);
const paying = ref(false);
const settlingUserId = ref('');
const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const countries = ref<Array<Record<string, any>>>([]);
const countryKeyword = ref('');
const resultOpen = ref(false);
const resultRows = ref<Array<Record<string, any>>>([]);
const selectedUserIds = ref<string[]>([]);
const salarySortKey = ref<'' | SalarySortKey>('');
const salarySortOrder = ref<SalarySortOrder>('');
const query = reactive({
countryCode: '',
cursor: 1,
limit: 20,
sortField: '',
sortOrder: '' as SalarySortOrder,
sysOrigin: '',
userId: '',
});
const columns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户信息', width: 300 },
{ dataIndex: 'countryCode', key: 'countryCode', title: '国家', width: 90 },
{ dataIndex: 'lastSettlementTarget', key: 'lastSettlementTarget', title: '上次结算的积分', width: 150 },
{ dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 130 },
{ dataIndex: 'policyLevel', key: 'policyLevel', title: '本次积分档位', width: 130 },
{ dataIndex: 'expectedMemberSalary', key: 'expectedMemberSalary', title: '主播应发', width: 120 },
{ dataIndex: 'expectedAgentSalary', key: 'expectedAgentSalary', title: '代理应发', width: 120 },
{ dataIndex: 'expectedSalary', key: 'expectedSalary', title: '应发合计', width: 120 },
{ dataIndex: 'payableMemberSalary', key: 'payableMemberSalary', title: '主播差额', width: 120 },
{ dataIndex: 'payableAgentSalary', key: 'payableAgentSalary', title: '代理差额', width: 120 },
{ dataIndex: 'payableSalary', key: 'payableSalary', title: '合计差额', width: 140 },
{ dataIndex: 'memberIssuedSalary', key: 'memberIssuedSalary', title: '主播实收', width: 140 },
{ dataIndex: 'agentIssuedSalary', key: 'agentIssuedSalary', title: '代理实收', width: 140 },
{ dataIndex: 'totalIssuedSalary', key: 'totalIssuedSalary', title: '总发工资', width: 140 },
{ dataIndex: 'currentSalaryBalance', key: 'currentSalaryBalance', title: '当前工资', width: 140 },
{ dataIndex: 'action', fixed: 'right', key: 'action', title: '操作', width: 110 },
];
const resultColumns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户信息', width: 280 },
{ dataIndex: 'countryCode', key: 'countryCode', title: '国家', width: 90 },
{ dataIndex: 'salaryChange', key: 'salaryChange', title: '工资变动', width: 180 },
{ dataIndex: 'issuedSalary', key: 'issuedSalary', title: '发放工资', width: 120 },
{ dataIndex: 'policyLevel', key: 'policyLevel', title: '档位', width: 90 },
{ dataIndex: 'currentTarget', key: 'currentTarget', title: '本次积分', width: 120 },
];
const salarySortableKeys = new Set<SalarySortKey>([
'payableSalary',
'totalIssuedSalary',
'currentSalaryBalance',
]);
const hasMore = computed(() => list.value.length < total.value);
const tableRows = computed(() =>
list.value.map((item) => ({
...item,
rowKey: getRowKey(item),
})),
);
const summaryText = computed(() => {
if (total.value <= 0) {
return list.value.length > 0 ? `已加载 ${list.value.length}` : '';
}
return `已加载 ${list.value.length}/${total.value}`;
});
const selectedUserCount = computed(() => selectedUserIds.value.length);
const confirmButtonText = computed(() =>
selectedUserCount.value > 0
? `确认发放(${selectedUserCount.value}人)`
: '确认发放',
);
const rowSelection = computed(() => ({
getCheckboxProps: (record: Record<string, any>) => ({
disabled: !record.userId,
}),
onChange: (keys: Array<number | string>) => {
selectedUserIds.value = keys.map(String).filter(Boolean);
},
preserveSelectedRowKeys: true,
selectedRowKeys: selectedUserIds.value,
}));
const filteredCountries = computed(() => {
const keyword = countryKeyword.value.trim().toLowerCase();
if (!keyword) {
return countries.value;
}
return countries.value.filter((item) =>
[
item.phonePrefix,
item.countryName,
item.aliasName,
item.alphaTwo,
item.alphaThree,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(keyword),
);
});
watch(
sysOriginOptions,
(options) => {
if (!query.sysOrigin && options.length > 0) {
query.sysOrigin = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
[() => query.sysOrigin, () => query.countryCode, () => query.userId],
() => {
selectedUserIds.value = [];
},
);
async function loadCountries() {
countries.value = (await getCountryAlls()) || [];
}
async function loadData(reset = false, append = false) {
if (!query.sysOrigin) {
return;
}
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
if (reset) {
query.cursor = 1;
list.value = [];
total.value = 0;
selectedUserIds.value = [];
}
loading.value = true;
try {
const result = await pageManualSalary({ ...query });
const records = result.records || [];
list.value = append ? [...list.value, ...records] : records;
total.value = Number(result.total || 0);
sortLoadedRows();
} finally {
loading.value = false;
}
}
async function loadNextPage() {
if (loading.value || !hasMore.value) {
return;
}
const previousCursor = query.cursor;
query.cursor = previousCursor + 1;
try {
await loadData(false, true);
} catch (error) {
query.cursor = previousCursor;
throw error;
}
}
function formatMoney(value?: number | string) {
const amount = Number(value || 0);
if (Number.isNaN(amount)) {
return '$0.00';
}
return `$${amount.toFixed(2)}`;
}
function formatInteger(value?: number | string) {
if (value === undefined || value === null || value === '') {
return '-';
}
return Number(value).toLocaleString('en-US');
}
function countryLabel(code?: string) {
const country = countries.value.find((item) => item.alphaTwo === code);
if (!country) {
return code || '-';
}
return `${country.phonePrefix || ''} ${country.countryName || country.aliasName || code}`;
}
function getRowKey(record: Record<string, any>) {
return String(record.userId || record.id || '');
}
function getSalarySortValue(
record: Record<string, any>,
key: SalarySortKey,
) {
const value = Number(record?.[key] || 0);
return Number.isFinite(value) ? value : 0;
}
function isSalarySortableColumn(key?: number | string) {
return salarySortableKeys.has(String(key || '') as SalarySortKey);
}
function salarySortIndicator(key?: number | string) {
if (salarySortKey.value !== String(key || '') || !salarySortOrder.value) {
return '排序';
}
return salarySortOrder.value === 'descend' ? '降序' : '升序';
}
function sortLoadedRows() {
if (!salarySortKey.value || !salarySortOrder.value) {
return;
}
const sortKey = salarySortKey.value;
const direction = salarySortOrder.value === 'ascend' ? 1 : -1;
list.value = [...list.value].sort((left, right) => {
const diff =
getSalarySortValue(left, sortKey) - getSalarySortValue(right, sortKey);
if (diff !== 0) {
return diff * direction;
}
return getRowKey(left).localeCompare(getRowKey(right));
});
}
function toggleSalarySort(key?: number | string) {
if (!isSalarySortableColumn(key)) {
return;
}
const nextKey = String(key) as SalarySortKey;
if (salarySortKey.value !== nextKey) {
salarySortKey.value = nextKey;
salarySortOrder.value = 'descend';
} else if (salarySortOrder.value === 'descend') {
salarySortOrder.value = 'ascend';
} else if (salarySortOrder.value === 'ascend') {
salarySortKey.value = '';
salarySortOrder.value = '';
} else {
salarySortOrder.value = 'descend';
}
query.sortField = salarySortKey.value;
query.sortOrder = salarySortOrder.value;
void loadData(true);
}
async function submitManualSalary(userIds?: string[]) {
const hasSelectedUsers = !!userIds?.length;
resultRows.value = await confirmManualSalary({
countryCode: query.countryCode,
sysOrigin: query.sysOrigin,
...(hasSelectedUsers ? { userIds } : {}),
});
resultOpen.value = true;
selectedUserIds.value = [];
await loadData(true);
}
function confirmPay() {
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
const userIds = [...selectedUserIds.value];
const hasSelectedUsers = userIds.length > 0;
Modal.confirm({
content: hasSelectedUsers
? '本次只发放已勾选的用户。'
: '未勾选用户,本次将发放当前筛选国家的所有可发工资用户。',
title: hasSelectedUsers
? `确认发放已选择的 ${userIds.length} 人当前账期工资?`
: `确认发放 ${countryLabel(query.countryCode)} 当前账期工资?`,
async onOk() {
paying.value = true;
try {
await submitManualSalary(hasSelectedUsers ? userIds : undefined);
message.success('发放成功');
} finally {
paying.value = false;
}
},
});
}
function settleUser(record: Record<string, any>) {
if (!query.countryCode) {
message.warning('请选择国家');
return;
}
const userId = String(record.userId || '');
if (!userId) {
message.warning('用户ID为空');
return;
}
const payableSalary = Number(record.payableSalary || 0);
Modal.confirm({
content: payableSalary < 0
? '该用户应发小于实收本次发放0并置为已结算不会对用户扣款。'
: '本次只结算当前这一行用户的工资。',
title: `确认结算用户 ${record.userProfile?.account || userId} 的当前账期工资?`,
async onOk() {
settlingUserId.value = userId;
try {
await submitManualSalary([userId]);
message.success('结算成功');
} finally {
settlingUserId.value = '';
}
},
});
}
void loadCountries();
</script>
<template>
<Page
auto-content-height
content-class="manual-salary-page-content"
title="工资发放"
>
<div class="manual-salary-page">
<Card class="manual-salary-filter-card">
<Space wrap>
<SysOriginSelect
v-model:value="query.sysOrigin"
:options="sysOriginOptions"
style="width: 140px"
/>
<Select
v-model:value="query.countryCode"
allow-clear
show-search
:filter-option="false"
option-label-prop="label"
placeholder="国家"
style="width: 240px"
:options="filteredCountries.map((item) => ({
label: `${item.phonePrefix || ''} ${item.countryName || item.aliasName || ''}`,
value: item.alphaTwo as any,
}))"
@change="loadData(true)"
@search="countryKeyword = $event"
/>
<AccountInput
v-model:value="query.userId"
:sys-origin="query.sysOrigin"
placeholder="用户ID"
style="width: 260px"
/>
<Button :loading="loading" type="primary" @click="loadData(true)">
搜索
</Button>
<Button
danger
:disabled="!query.countryCode"
:loading="paying"
@click="confirmPay"
>
{{ confirmButtonText }}
</Button>
</Space>
</Card>
<AdminConfigTable
auto-height
:columns="columns"
:current="query.cursor"
:data-source="tableRows"
:has-more="hasMore"
infinite
:loading="loading"
:page-size="query.limit"
row-key="rowKey"
:row-selection="rowSelection"
:scroll-x="2340"
:show-pager="false"
:summary-text="summaryText"
:total="total"
@load-more="loadNextPage"
>
<template #headerCell="{ column }">
<button
v-if="isSalarySortableColumn(column.key)"
class="salary-sort-header"
:class="{ 'salary-sort-header--active': salarySortKey === column.key }"
type="button"
@click="toggleSalarySort(column.key)"
>
<span>{{ column.title }}</span>
<span class="salary-sort-state">
{{ salarySortIndicator(column.key) }}
</span>
</button>
<template v-else>
{{ column.title }}
</template>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
</template>
<template v-else-if="column.key === 'countryCode'">
{{ record.countryCode || '-' }}
</template>
<template v-else-if="column.key === 'lastSettlementTarget'">
{{ formatInteger(record.lastSettlementTarget) }}
</template>
<template v-else-if="column.key === 'currentTarget'">
{{ formatInteger(record.currentTarget) }}
</template>
<template v-else-if="column.key === 'policyLevel'">
<Tag v-if="record.policyLevel" color="blue">
{{ record.policyLevel }}
</Tag>
<span v-else>-</span>
</template>
<template v-else-if="column.key === 'expectedSalary'">
{{ formatMoney(record.expectedSalary) }}
</template>
<template v-else-if="column.key === 'expectedMemberSalary'">
{{ formatMoney(record.expectedMemberSalary) }}
</template>
<template v-else-if="column.key === 'expectedAgentSalary'">
{{ formatMoney(record.expectedAgentSalary) }}
</template>
<template v-else-if="column.key === 'payableMemberSalary'">
<span
:class="{ 'payable-salary-negative': Number(record.payableMemberSalary || 0) < 0 }"
>
{{ formatMoney(record.payableMemberSalary) }}
</span>
</template>
<template v-else-if="column.key === 'payableAgentSalary'">
<span
:class="{ 'payable-salary-negative': Number(record.payableAgentSalary || 0) < 0 }"
>
{{ formatMoney(record.payableAgentSalary) }}
</span>
</template>
<template v-else-if="column.key === 'payableSalary'">
<Space size="small">
<span
:class="{ 'payable-salary-negative': Number(record.payableSalary || 0) < 0 }"
>
{{ formatMoney(record.payableSalary) }}
</span>
<Tag v-if="record.paid" color="green">已结算</Tag>
</Space>
</template>
<template v-else-if="column.key === 'memberIssuedSalary'">
<span>{{ formatMoney(record.memberIssuedSalary) }}</span>
<span
v-if="Number(record.memberOverIssuedSalary || 0) > 0"
class="over-issued-salary"
>
({{ formatMoney(record.memberOverIssuedSalary) }})
</span>
</template>
<template v-else-if="column.key === 'agentIssuedSalary'">
<span>{{ formatMoney(record.agentIssuedSalary) }}</span>
<span
v-if="Number(record.agentOverIssuedSalary || 0) > 0"
class="over-issued-salary"
>
({{ formatMoney(record.agentOverIssuedSalary) }})
</span>
</template>
<template v-else-if="column.key === 'totalIssuedSalary'">
<span>{{ formatMoney(record.totalIssuedSalary) }}</span>
<span
v-if="Number(record.overIssuedSalary || 0) > 0"
class="over-issued-salary"
>
({{ formatMoney(record.overIssuedSalary) }})
</span>
</template>
<template v-else-if="column.key === 'currentSalaryBalance'">
{{ formatMoney(record.currentSalaryBalance) }}
</template>
<template v-else-if="column.key === 'action'">
<Button
:disabled="!record.userId || record.paid"
:loading="settlingUserId === String(record.userId || '')"
size="small"
type="primary"
@click="settleUser(record)"
>
结算
</Button>
</template>
</template>
</AdminConfigTable>
</div>
<Modal
v-model:open="resultOpen"
:footer="null"
title="发放结果"
width="980px"
>
<Table
:columns="resultColumns"
:data-source="resultRows"
:pagination="false"
row-key="id"
size="small"
:scroll="{ x: 900, y: 520 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
</template>
<template v-else-if="column.key === 'salaryChange'">
{{ formatMoney(record.beforeSalaryBalance) }} -> {{ formatMoney(record.afterSalaryBalance) }}
</template>
<template v-else-if="column.key === 'issuedSalary'">
{{ formatMoney(record.issuedSalary) }}
</template>
<template v-else-if="column.key === 'policyLevel'">
<Tag color="blue">{{ record.policyLevel || '-' }}</Tag>
</template>
<template v-else-if="column.key === 'currentTarget'">
{{ formatInteger(record.currentTarget) }}
</template>
</template>
</Table>
</Modal>
</Page>
</template>
<style scoped>
.manual-salary-page {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
min-height: 0;
}
.manual-salary-filter-card {
flex: 0 0 auto;
}
.salary-sort-header {
align-items: center;
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 6px;
max-width: 100%;
padding: 0;
text-align: left;
}
.salary-sort-header:hover,
.salary-sort-header--active {
color: rgb(22 119 255);
}
.salary-sort-state {
color: rgb(102 112 133);
flex: 0 0 auto;
font-size: 12px;
font-weight: 400;
}
.salary-sort-header--active .salary-sort-state {
color: rgb(22 119 255);
}
.over-issued-salary {
color: rgb(217 45 32);
margin-left: 4px;
}
.payable-salary-negative {
color: rgb(217 45 32);
font-weight: 600;
}
</style>

View File

@ -8,10 +8,21 @@ import {
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
Button,
Card,
Input,
message,
Modal,
Space,
Table,
} from 'antdv-next';
import {
confirmImport,
deductMoney,
exprotBank,
getBankStatistics,
pageBank,
sendMoney,
transferMoney,
@ -20,16 +31,6 @@ import AccountInput from '#/components/account-input.vue';
import { formatDate,
getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Input,
Modal,
Space,
Table,
message,
} from 'antdv-next';
import UserBankCreateModal from './components/user-bank-create-modal.vue';
import UserBankRunningWaterModal from './components/user-bank-running-water-modal.vue';
import UserProfileLink from './components/user-profile-link.vue';
@ -45,6 +46,7 @@ const sysOriginOptions = computed(() => {
const loading = ref(false);
const loadMoreLoading = ref(false);
const exportLoading = ref(false);
const statisticsLoading = ref(false);
const notData = ref(false);
const list = ref<Array<Record<string, any>>>([]);
const createOpen = ref(false);
@ -54,16 +56,33 @@ const importLoading = ref(false);
const actionModalOpen = ref(false);
const transferOpen = ref(false);
const actionType = ref<'deduct' | 'send'>('send');
const activeRow = ref<Record<string, any> | null>(null);
const activeRow = ref<null | Record<string, any>>(null);
const importInput = ref<HTMLInputElement | null>(null);
const query = reactive({
lastId: '',
limit: 20,
pageNo: 1,
sortField: '',
sortOrder: '',
sysOrigin: '',
userId: '',
});
const statistics = reactive({
totalBalance: 0,
totalConsumptionPoints: 0,
totalEarnPoints: 0,
});
const sortableAmountKeys = new Set(['balance', 'consumptionPoints', 'earnPoints']);
type BankSortOrder = 'ascend' | 'descend';
const isSortActive = computed(() => {
return Boolean(query.sortField && query.sortOrder);
});
const actionForm = reactive({
quantity: '',
remark: '',
@ -79,17 +98,96 @@ const transferForm = reactive({
userId: '',
});
const columns = [
const columns = computed(() => [
{ dataIndex: 'index', key: 'index', title: 'No', width: 70 },
{ dataIndex: 'sysOrigin', key: 'sysOrigin', title: '系统', width: 90 },
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户', width: 300 },
{ dataIndex: 'earnPoints', key: 'earnPoints', title: '获得总额', width: 120 },
{ dataIndex: 'consumptionPoints', key: 'consumptionPoints', title: '消费总额', width: 120 },
{ dataIndex: 'balance', key: 'balance', title: '余额', width: 120 },
{
align: 'right' as const,
dataIndex: 'earnPoints',
key: 'earnPoints',
sorter: true,
sortOrder: getSortOrder('earnPoints'),
title: '获得总额',
width: 120,
},
{
align: 'right' as const,
dataIndex: 'consumptionPoints',
key: 'consumptionPoints',
sorter: true,
sortOrder: getSortOrder('consumptionPoints'),
title: '消费总额',
width: 120,
},
{
align: 'right' as const,
dataIndex: 'balance',
key: 'balance',
sorter: true,
sortOrder: getSortOrder('balance'),
title: '余额',
width: 120,
},
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
{ dataIndex: 'updateTime', key: 'updateTime', title: '修改时间', width: 180 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 260, fixed: 'right' as const },
];
]);
function isBankSortOrder(value: string): value is BankSortOrder {
return value === 'ascend' || value === 'descend';
}
function getSortOrder(field: string) {
return query.sortField === field && isBankSortOrder(query.sortOrder)
? query.sortOrder
: undefined;
}
function formatAmount(value: any) {
const rawValue = String(value ?? '0').replaceAll(',', '');
if (!rawValue || rawValue === 'null' || rawValue === 'undefined') {
return '0';
}
const [integerPart = '0', decimalPart = ''] = rawValue.split('.');
const isNegative = integerPart.startsWith('-');
const unsignedInteger = isNegative ? integerPart.slice(1) : integerPart;
const formattedInteger =
unsignedInteger.replaceAll(/\B(?=(\d{3})+(?!\d))/g, ',') || '0';
const trimmedDecimal = decimalPart.replaceAll(/0+$/g, '');
return `${isNegative ? '-' : ''}${formattedInteger}${trimmedDecimal ? `.${trimmedDecimal}` : ''}`;
}
function buildListParams() {
return {
...query,
lastId: isSortActive.value ? undefined : query.lastId,
pageNo: isSortActive.value ? query.pageNo : undefined,
sortField: isSortActive.value ? query.sortField : undefined,
sortOrder: isSortActive.value ? query.sortOrder : undefined,
};
}
function buildStatisticsParams() {
return {
sysOrigin: query.sysOrigin,
};
}
async function loadStatistics() {
if (!query.sysOrigin) {
return;
}
statisticsLoading.value = true;
try {
const result = await getBankStatistics(buildStatisticsParams());
statistics.totalEarnPoints = result?.totalEarnPoints ?? 0;
statistics.totalConsumptionPoints = result?.totalConsumptionPoints ?? 0;
statistics.totalBalance = result?.totalBalance ?? 0;
} finally {
statisticsLoading.value = false;
}
}
watch(
sysOriginOptions,
@ -107,6 +205,7 @@ async function loadData(reset = false) {
}
if (reset) {
query.lastId = '';
query.pageNo = 1;
list.value = [];
notData.value = false;
}
@ -116,12 +215,20 @@ async function loadData(reset = false) {
loadMoreLoading.value = true;
}
try {
const result = await pageBank({ ...query });
const result = reset
? await Promise.all([pageBank(buildListParams()), loadStatistics()]).then(
([rows]) => rows,
)
: await pageBank(buildListParams());
const current = result || [];
notData.value = current.length <= 0;
if (!notData.value) {
notData.value = current.length < Number(query.limit || 20);
if (current.length > 0) {
list.value = [...list.value, ...current];
query.lastId = String(list.value[list.value.length - 1]?.id || '');
if (isSortActive.value) {
query.pageNo += 1;
} else {
query.lastId = String(list.value[list.value.length - 1]?.id || '');
}
}
} finally {
loading.value = false;
@ -133,6 +240,20 @@ function handleSearch() {
void loadData(true);
}
function handleTableChange(_: any, __: any, sorter: any) {
const activeSorter = Array.isArray(sorter) ? sorter[0] : sorter;
const field = String(activeSorter?.columnKey || '');
const order = String(activeSorter?.order || '');
if (isBankSortOrder(order) && sortableAmountKeys.has(field)) {
query.sortField = field;
query.sortOrder = order;
} else {
query.sortField = '';
query.sortOrder = '';
}
void loadData(true);
}
function openAction(record: Record<string, any>, type: 'deduct' | 'send') {
activeRow.value = record;
actionType.value = type;
@ -165,11 +286,7 @@ async function submitAction() {
}
loading.value = true;
try {
if (actionType.value === 'send') {
await sendMoney({ ...actionForm });
} else {
await deductMoney({ ...actionForm });
}
await (actionType.value === 'send' ? sendMoney : deductMoney)({ ...actionForm });
message.success('操作成功');
actionModalOpen.value = false;
await loadData(true);
@ -252,11 +369,28 @@ watch(
<template>
<Page title="用户银行账户">
<Card>
<div class="statistics-card" :class="{ 'is-loading': statisticsLoading }">
<div class="statistics-item">
<span class="statistics-label">所有用户获得总额</span>
<strong>{{ formatAmount(statistics.totalEarnPoints) }}</strong>
</div>
<div class="statistics-item">
<span class="statistics-label">所有用户消费总额</span>
<strong>{{ formatAmount(statistics.totalConsumptionPoints) }}</strong>
</div>
<div class="statistics-item">
<span class="statistics-label">所有用户余额</span>
<strong>{{ formatAmount(statistics.totalBalance) }}</strong>
</div>
</div>
<div class="toolbar">
<Space wrap>
<SysOriginSelect v-model:value="query.sysOrigin" style="width: 140px"
<SysOriginSelect
v-model:value="query.sysOrigin"
:options="sysOriginOptions"
></SysOriginSelect>
style="width: 140px"
/>
<AccountInput
v-model:value="query.userId"
:sys-origin="query.sysOrigin"
@ -279,6 +413,7 @@ watch(
:pagination="false"
row-key="id"
:scroll="{ x: 1500 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
@ -287,6 +422,15 @@ watch(
<template v-else-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
</template>
<template
v-else-if="
column.key === 'earnPoints' ||
column.key === 'consumptionPoints' ||
column.key === 'balance'
"
>
{{ formatAmount(record[column.key]) }}
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
@ -438,7 +582,7 @@ watch(
class="hidden-input"
type="file"
@change="handleImportChange"
>
/>
<Button :loading="importLoading" block @click="chooseImportFile">
选择文件并导入
</Button>
@ -448,6 +592,39 @@ watch(
</template>
<style scoped>
.statistics-card {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
padding: 14px 16px;
margin-bottom: 16px;
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.statistics-card.is-loading {
opacity: 0.68;
}
.statistics-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.statistics-label {
color: #64748b;
font-size: 13px;
}
.statistics-item strong {
color: #0f172a;
font-size: 20px;
font-weight: 600;
line-height: 1.25;
}
.toolbar {
margin-bottom: 16px;
}

View File

@ -79,6 +79,13 @@ interface StructuredResourceFile {
type: string;
}
interface UploadTask {
file: File;
fileName: string;
kind: AssetKind;
row: BatchRow;
}
const props = defineProps<{
initialSysOrigin?: number | string;
initialType?: number | string;
@ -109,6 +116,9 @@ const sequence = ref(0);
const showOnlyErrors = ref(false);
const submitting = ref(false);
const droppedFilePathMap = new WeakMap<File, string>();
const uploadQueue: UploadTask[] = [];
const MAX_UPLOAD_CONCURRENCY = 1;
let activeUploadCount = 0;
const propsTypeOptions = PROPS_RESOURCE_CONFIG_TYPES.map((item) => ({
label: item.name,
@ -243,6 +253,7 @@ function resetBatch() {
activeRowKey.value = '';
showOnlyErrors.value = false;
sequence.value = 0;
uploadQueue.length = 0;
}
watch(
@ -456,7 +467,26 @@ function setRowAsset(row: BatchRow, kind: AssetKind, file: File) {
}
row.saved = false;
refreshAllRows();
void uploadAsset(row, kind, file, fileName);
enqueueUpload({ file, fileName, kind, row });
}
function enqueueUpload(task: UploadTask) {
uploadQueue.push(task);
runUploadQueue();
}
function runUploadQueue() {
while (activeUploadCount < MAX_UPLOAD_CONCURRENCY && uploadQueue.length > 0) {
const task = uploadQueue.shift();
if (!task) {
return;
}
activeUploadCount += 1;
void uploadAsset(task.row, task.kind, task.file, task.fileName).finally(() => {
activeUploadCount -= 1;
runUploadQueue();
});
}
}
async function uploadAsset(
@ -1410,6 +1440,18 @@ function customRow(record: Record<string, any>) {
width: 52px;
}
.asset-cover {
border-radius: 6px;
display: block;
overflow: hidden;
}
.asset-cover :deep(.ant-image-img) {
height: 52px;
object-fit: contain;
width: 52px;
}
.asset-empty {
align-items: center;
background: #f8fafc;

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,8 @@ const adminFreeOptions = [
];
const DEFAULT_STORE_CURRENCY_TYPES = 'GOLD';
const DEFAULT_STORE_VALID_DAYS = '7';
const DEFAULT_RESOURCE_CONFIG_TYPE =
(PROPS_RESOURCE_CONFIG_TYPES[0]?.value as string | undefined) ?? 'AVATAR_FRAME';
const query = reactive({
cursor: 1,
@ -97,9 +99,7 @@ const query = reactive({
limit: 20,
name: '',
sysOrigin: sysOriginOptions.value[0]?.value ?? 'LIKEI',
type: (PROPS_RESOURCE_CONFIG_TYPES[0]?.value ?? 'AVATAR_FRAME') as
| string
| undefined,
type: DEFAULT_RESOURCE_CONFIG_TYPE as string | undefined,
});
const columns = [
@ -203,7 +203,7 @@ function resetColumnFilter(key: unknown) {
break;
}
case 'typeName': {
query.type = undefined;
query.type = DEFAULT_RESOURCE_CONFIG_TYPE;
break;
}
}
@ -243,7 +243,7 @@ function resetAllColumnFilters() {
query.del = undefined;
query.id = '';
query.name = '';
query.type = undefined;
query.type = DEFAULT_RESOURCE_CONFIG_TYPE;
void loadData(true);
}
@ -598,7 +598,6 @@ loadData(true);
<template v-else-if="column.key === 'typeName'">
<Select
v-model:value="query.type"
allow-clear
option-label-prop="label"
placeholder="选择类型"
:options="PROPS_RESOURCE_CONFIG_TYPES.map((item) => ({ label: `${item.name}`, value: item.value as any }))"

View File

@ -13,12 +13,12 @@ import {
InputNumber,
message,
Pagination,
Select,
Spin,
Tag,
} from 'antdv-next';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
sendPropsGiveUser,
@ -31,7 +31,7 @@ import { BADGE_TYPE_OPTIONS, PROPS_TYPES } from './shared';
defineOptions({ name: 'PropsSendTool' });
type ResourceKind = 'badge' | 'props';
type ResourceKind = 'badge' | 'gift' | 'props';
type TypeOption = {
icon: string;
@ -50,6 +50,7 @@ type DisplayResource = {
name: string;
raw: Record<string, any>;
secondaryType: string;
type: string;
typeLabel: string;
};
@ -62,9 +63,11 @@ type RecipientProfile = {
};
const BADGE_TYPE_VALUE = '__BADGE__';
const GIFT_TYPE_VALUE = '__GIFT__';
const MAX_BATCH_ACCOUNTS = 15;
const RESOURCE_PAGE_SIZE = 72;
const QUICK_DAYS = [1, 7, 15, 30, -1, -7];
const QUICK_GIFT_QUANTITIES = [1, 5, 10, 50, 100];
const USER_AVATAR_FALLBACK =
'https://dummyimage.com/40x40/e2e8f0/64748b&text=U';
@ -76,14 +79,7 @@ const sysOriginOptions = computed(() => {
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const propsTypeOptions = PROPS_TYPES.filter(
(item) => item.value !== 'CUSTOMIZE' && item.value !== 'FRAGMENTS',
);
const vipOriginOptions = [
{ label: '系统赠送', value: 'SYSTEM_GIVE' as any },
{ label: '购买或朋友赠送', value: 'BUY_OR_GIVE' as any },
{ label: '活动奖励', value: 'ACTIVITY_AWARD' as any },
];
const propsTypeOptions = PROPS_TYPES.filter((item) => item.value !== 'CUSTOMIZE');
const propsFormLoading = ref(false);
const sourceLoading = ref(false);
@ -98,10 +94,10 @@ let recipientSearchTimer: number | undefined;
const form = reactive({
exchangeDays: '',
giftQuantity: 1,
receiverAccounts: '',
secondaryType: '',
sysOrigin: sysOriginOptions.value[0]?.value ?? '',
vipOrigin: '',
});
function hasPermission(code: string) {
@ -113,7 +109,12 @@ const canSendProps = computed(() => hasPermission('send:props'));
const canSendBadge = computed(
() => canSendProps.value || hasPermission('send:props:badge'),
);
const canUsePage = computed(() => canSendProps.value || canSendBadge.value);
const canSendGift = computed(
() => canSendProps.value || hasPermission('send:props:GIFT'),
);
const canUsePage = computed(
() => canSendProps.value || canSendBadge.value || canSendGift.value,
);
const typeOptions = computed<TypeOption[]>(() => {
const options: TypeOption[] = [];
@ -135,6 +136,14 @@ const typeOptions = computed<TypeOption[]>(() => {
value: BADGE_TYPE_VALUE,
});
}
if (canSendGift.value) {
options.push({
icon: 'lucide:gift',
kind: 'gift',
label: '礼物',
value: GIFT_TYPE_VALUE,
});
}
return options;
});
@ -156,14 +165,27 @@ const displayedRecipients = computed(() =>
);
}),
);
const sendActionText = computed(() => (Number(form.exchangeDays) < 0 ? '回收' : '赠送'));
const selectedCount = computed(() => selectedResources.value.length);
const needsVipOrigin = computed(
() =>
selectedResources.value.some(
(item) => item.kind === 'props' && item.secondaryType === 'NOBLE_VIP',
),
const selectedHasGift = computed(() =>
selectedResources.value.some((item) => item.kind === 'gift'),
);
const selectedHasNonGift = computed(() =>
selectedResources.value.some((item) => item.kind !== 'gift'),
);
const currentTypeIsGift = computed(() => currentTypeOption.value?.kind === 'gift');
const showGiftQuantity = computed(() => selectedHasGift.value || currentTypeIsGift.value);
const showExchangeDays = computed(
() => selectedHasNonGift.value || (!selectedHasGift.value && !currentTypeIsGift.value),
);
const sendActionText = computed(() => {
if (selectedHasGift.value && selectedHasNonGift.value) {
return Number(form.exchangeDays) < 0 ? '礼物赠送 / 道具回收' : '赠送';
}
if (selectedHasGift.value) {
return '赠送';
}
return Number(form.exchangeDays) < 0 ? '回收' : '赠送';
});
const selectedTypeSummary = computed(() => {
const labels = [
...new Set(selectedResources.value.map((item) => item.typeLabel).filter(Boolean)),
@ -331,17 +353,19 @@ function normalizeDisplayResource(
secondaryType: string,
typeLabel: string,
): DisplayResource {
const id =
kind === 'badge'
? firstPresent(item, ['badgeConfigId', 'id', 'badgeId'])
: firstPresent(item, ['id', 'resourceId', 'propsSourceId', 'propsId']);
const name =
kind === 'badge'
? firstPresent(item, ['badgeName', 'name'])
: firstPresent(item, ['name', 'resourceName', 'sourceName']);
let id = firstPresent(item, ['id', 'resourceId', 'propsSourceId', 'propsId']);
let name = firstPresent(item, ['name', 'resourceName', 'sourceName']);
if (kind === 'badge') {
id = firstPresent(item, ['badgeConfigId', 'id', 'badgeId']);
name = firstPresent(item, ['badgeName', 'name']);
} else if (kind === 'gift') {
id = firstPresent(item, ['id', 'giftId']);
name = firstPresent(item, ['giftName', 'name']);
}
const cover = firstPresent(item, [
'cover',
'coverUrl',
'giftPhoto',
'resourceCover',
'resourceCoverUrl',
'selectUrl',
@ -349,7 +373,7 @@ function normalizeDisplayResource(
]);
return {
amount: firstPresent(item, ['amount', 'price', 'goldAmount']),
amount: firstPresent(item, ['amount', 'giftCandy', 'price', 'goldAmount']),
badgeTypeLabel: kind === 'badge' ? typeLabel : '',
cover: String(cover || ''),
id: id as number | string,
@ -358,6 +382,7 @@ function normalizeDisplayResource(
name: String(name || (id ? `资源 ${id}` : '-')),
raw: item,
secondaryType,
type: secondaryType,
typeLabel,
};
}
@ -387,12 +412,15 @@ function setQuickDays(value: number) {
form.exchangeDays = String(value);
}
function setQuickGiftQuantity(value: number) {
form.giftQuantity = value;
}
function selectType(option: TypeOption) {
if (form.secondaryType === option.value) {
return;
}
form.secondaryType = option.value;
form.vipOrigin = option.value === 'NOBLE_VIP' ? form.vipOrigin : '';
resourceKeyword.value = '';
resourcePage.value = 1;
sourceOptions.value = [];
@ -429,6 +457,14 @@ async function loadSources() {
return;
}
if (type.kind === 'gift') {
const rows = await listGiftBySysOrigin(form.sysOrigin);
sourceOptions.value = (rows || []).map((item) =>
normalizeDisplayResource(item, 'gift', 'GIFT', '礼物'),
);
return;
}
const rows = await listNotFamilyBySysOriginType(form.sysOrigin, type.value);
sourceOptions.value = (rows || []).map((item) =>
normalizeDisplayResource(item, 'props', type.value, type.label),
@ -483,18 +519,37 @@ function validateBeforeSend() {
message.warning('请选择资源');
return null;
}
if (form.exchangeDays === '' || Number.isNaN(Number(form.exchangeDays))) {
if (
selectedHasNonGift.value &&
(form.exchangeDays === '' || Number.isNaN(Number(form.exchangeDays)))
) {
message.warning('请输入有效天数');
return null;
}
if (needsVipOrigin.value && !form.vipOrigin) {
message.warning('请选择贵族来源');
if (
selectedHasGift.value &&
(!Number.isFinite(Number(form.giftQuantity)) || Number(form.giftQuantity) <= 0)
) {
message.warning('请输入礼物数量');
return null;
}
return accounts;
}
function buildSendPayload(resource: DisplayResource, accounts: string[]) {
if (resource.kind === 'gift') {
const quantity = Number(form.giftQuantity);
return {
content: resource.id,
exchangeDays: quantity,
quantity,
receiverAccounts: accounts.join(','),
secondaryType: 'GIFT',
sysOrigin: form.sysOrigin,
type: 'GIFT',
};
}
const payload: Record<string, any> = {
content: resource.id,
exchangeDays: Number(form.exchangeDays),
@ -504,11 +559,6 @@ function buildSendPayload(resource: DisplayResource, accounts: string[]) {
type: resource.kind === 'badge' ? 'BADGE' : 'PROPS',
};
if (resource.kind === 'props') {
payload.vipOrigin =
resource.secondaryType === 'NOBLE_VIP' ? form.vipOrigin : null;
}
return payload;
}
@ -542,7 +592,7 @@ function resetCurrentForm() {
form.receiverAccounts = '';
form.secondaryType = '';
form.exchangeDays = '';
form.vipOrigin = '';
form.giftQuantity = 1;
resourceKeyword.value = '';
resourcePage.value = 1;
sourceOptions.value = [];
@ -551,7 +601,7 @@ function resetCurrentForm() {
</script>
<template>
<Page title="道具赠送">
<Page title="资源赠送">
<div v-if="canUsePage" class="send-tool">
<div class="send-layout">
<section class="send-main">
@ -687,7 +737,7 @@ function resetCurrentForm() {
<aside class="send-summary">
<div class="summary-title">
<IconifyIcon icon="lucide:package-check" />
<span>赠送道具</span>
<span>赠送资源</span>
</div>
<div class="selected-panel">
@ -732,7 +782,7 @@ function resetCurrentForm() {
<div v-else class="summary-empty">未选择资源</div>
</div>
<div class="summary-control summary-control--days">
<div v-if="showExchangeDays" class="summary-control summary-control--days">
<div class="summary-control__row">
<label class="summary-control__label" for="props-send-days">
有效天数
@ -760,15 +810,33 @@ function resetCurrentForm() {
</div>
</div>
<div v-if="needsVipOrigin" class="summary-control">
<label class="summary-control__label">贵族来源</label>
<Select
v-model:value="form.vipOrigin"
:options="vipOriginOptions"
class="summary-vip-origin"
option-label-prop="label"
placeholder="请选择贵族来源"
/>
<div v-if="showGiftQuantity" class="summary-control summary-control--days">
<div class="summary-control__row">
<label class="summary-control__label" for="gift-send-quantity">
礼物数量
</label>
<InputNumber
id="gift-send-quantity"
v-model:value="form.giftQuantity"
:controls="false"
:min="1"
:precision="0"
class="summary-days-input"
placeholder="请输入数量"
/>
</div>
<div class="quick-days summary-quick-days">
<button
v-for="quantity in QUICK_GIFT_QUANTITIES"
:key="quantity"
:class="{ 'quick-day--active': Number(form.giftQuantity) === quantity }"
class="quick-day"
type="button"
@click="setQuickGiftQuantity(quantity)"
>
{{ quantity }}
</button>
</div>
</div>
<dl class="summary-list">
@ -1256,8 +1324,7 @@ function resetCurrentForm() {
font-weight: 600;
}
.summary-days-input,
.summary-vip-origin {
.summary-days-input {
width: 100%;
}

View File

@ -8,12 +8,8 @@ export interface OptionItem {
export const PROPS_TYPES: OptionItem[] = [
{ value: 'AVATAR_FRAME', name: '头像框' },
{ value: 'RIDE', name: '座驾' },
{ value: 'NOBLE_VIP', name: '贵族' },
{ value: 'THEME', name: '主题' },
{ value: 'LAYOUT', name: '装扮' },
{ value: 'CHAT_BUBBLE', name: '聊天气泡' },
{ value: 'FLOAT_PICTURE', name: '飘窗' },
{ value: 'FRAGMENTS', name: '碎片' },
{ value: 'DATA_CARD', name: '资料卡' },
{ value: 'SPECIAL_ID', name: '靓号' },
{ value: 'CUSTOMIZE', name: '自定义(只读)' },
@ -120,7 +116,7 @@ export const PROPS_SOURCE_GROUP_TYPE_MAP: Record<
> = {
AVATAR_FRAME: { value: 'PROPS', name: '头像框' },
RIDE: { value: 'PROPS', name: '座驾' },
NOBLE_VIP: { value: 'PROPS', name: '贵族' },
NOBLE_VIP: { value: 'PROPS', name: 'VIP' },
THEME: { value: 'PROPS', name: '房间背景主题' },
GIFT: { value: 'GIFT', name: '礼物' },
SPECIAL_ID: { value: 'SPECIAL_ID', name: '靓号' },
@ -252,6 +248,7 @@ export function getPropsTypeName(value?: null | string) {
return (
PROPS_TYPES.find((item) => item.value === value)?.name ||
PROPS_STORE_TYPES.find((item) => item.value === value)?.name ||
BADGE_TYPE_OPTIONS.find((item) => item.value === value)?.name ||
PROPS_SOURCE_GROUP_TYPE_MAP[value]?.name ||
value
);
@ -283,7 +280,12 @@ export function isPropsCouponType(type?: null | string) {
}
export function isBadgeRewardType(type?: null | string) {
return type === 'BADGE' || type === 'ROOM_BADGE' || type === 'HONOR_ACTIVITY';
return (
type === 'BADGE' ||
type === 'ROOM_BADGE' ||
type === 'HONOR_ACTIVITY' ||
BADGE_TYPE_OPTIONS.some((item) => item.value === type)
);
}
export function isPropsSourceType(type?: null | string) {
@ -322,6 +324,12 @@ export function formatRewardText(item: Record<string, any>) {
quantity <= 0 ? '永久' : `${quantity}`
}`;
}
if (detailType === 'NOBLE_VIP') {
const quantity = Number(item.quantity || 0);
return `${item.name || item.remark || item.content || '-'} ${
quantity <= 0 ? '' : `${quantity}`
}`.trim();
}
if (detailType === 'GIFT' || detailType === 'FRAGMENTS') {
return `数量:${item.quantity || 0}`;
}

View File

@ -96,6 +96,8 @@ const rankQuery = reactive({
limit: 20,
});
const autoRewardEnabledCache = new Map<string, boolean>();
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '成功', value: 'SUCCESS' },
@ -141,11 +143,19 @@ const enabledLevelCount = computed(() =>
function normalizeConfig(result: Record<string, any> | null | undefined) {
const value = result || {};
form.autoRewardEnabled = value.autoRewardEnabled !== false;
const sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
if (Object.prototype.hasOwnProperty.call(value, 'autoRewardEnabled')) {
form.autoRewardEnabled = value.autoRewardEnabled !== false;
autoRewardEnabledCache.set(sysOrigin, form.autoRewardEnabled);
} else if (autoRewardEnabledCache.has(sysOrigin)) {
form.autoRewardEnabled = autoRewardEnabledCache.get(sysOrigin);
} else {
form.autoRewardEnabled = true;
}
form.configured = Boolean(value.configured);
form.enabled = Boolean(value.enabled);
form.id = value.id || 0;
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
form.sysOrigin = sysOrigin;
form.timezone = String(value.timezone || 'Asia/Riyadh');
form.updateTime = String(value.updateTime || '');
const levels = Array.isArray(value.levelConfigs) && value.levelConfigs.length > 0
@ -283,8 +293,9 @@ async function submitForm() {
}
saving.value = true;
try {
const autoRewardEnabled = Boolean(form.autoRewardEnabled);
await saveRoomTurnoverRewardConfig({
autoRewardEnabled: Boolean(form.autoRewardEnabled),
autoRewardEnabled,
enabled: Boolean(form.enabled),
id: form.id || 0,
levelConfigs: (form.levelConfigs || []).map((item: Record<string, any>) => ({
@ -297,6 +308,7 @@ async function submitForm() {
sysOrigin: selectedSysOrigin.value,
timezone: String(form.timezone || '').trim(),
});
autoRewardEnabledCache.set(selectedSysOrigin.value, autoRewardEnabled);
message.success('保存成功');
await reloadAll();
} finally {

View File

@ -4,9 +4,14 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
Avatar,
Button,
Card,
DatePicker,
Input,
InputNumber,
message,
@ -23,6 +28,7 @@ import {
import {
getResidentWheelConfig,
pageResidentWheelDrawRecords,
retryResidentWheelDrawRecord,
saveResidentWheelCategoryConfig,
} from '#/api/legacy/resident-activity';
import PropsSourceSelectDrawer from '#/views/operate/components/props-source-select-drawer.vue';
@ -30,7 +36,9 @@ import { getAllowedSysOrigins } from '#/views/system/shared';
defineOptions({ name: 'ResidentWheelConfigPage' });
const PROBABILITY_TOTAL = 10_000;
const { RangePicker } = DatePicker;
const PROBABILITY_TOTAL = 1_000_000;
const WHEEL_CATEGORIES = [
{
category: 'CLASSIC',
@ -69,6 +77,18 @@ const REWARD_TYPE_OPTIONS = [
{ label: '金币', value: 'GOLD' },
];
const WHEEL_RESOURCE_TYPE_OPTIONS = [
{ label: '头像框', value: 'AVATAR_FRAME' },
{ label: '座驾', value: 'RIDE' },
{ label: '礼物', value: 'GIFT' },
{ label: '用户徽章', value: 'BADGE' },
{ label: '房间徽章', value: 'ROOM_BADGE' },
{ label: '表情包', value: 'EMOJI' },
{ label: '聊天气泡', value: 'CHAT_BUBBLE' },
{ label: '飘窗', value: 'FLOAT_PICTURE' },
{ label: '资料卡', value: 'DATA_CARD' },
];
let localRowId = 0;
function normalizeRewardTypeValue(value: any) {
@ -95,6 +115,7 @@ function createReward(type = 'RESOURCE', sort = 1) {
enabled: true,
goldAmount: type === 'GOLD' ? 100 : 0,
id: 0,
issuedCount: 0,
probability: 0,
rewardType: type,
resourceId: null as null | number | string,
@ -103,6 +124,8 @@ function createReward(type = 'RESOURCE', sort = 1) {
resourceUrl: '',
rowKey: createRowKey(sort),
sort,
totalLimit: 0,
userLimit: 0,
};
}
@ -146,6 +169,7 @@ const loading = ref(false);
const savingCategory = ref('');
const recordLoading = ref(false);
const resourcePickerOpen = ref(false);
const retryingRecordId = ref<null | number | string>(null);
const pickerCategory = ref('CLASSIC');
const pickerRewardIndex = ref(-1);
const pickerRewardRecord = ref<null | Record<string, any>>(null);
@ -158,26 +182,40 @@ const recordQuery = reactive<Record<string, any>>({
status: '',
userId: '',
});
const recordRangeDate = ref<[Dayjs, Dayjs] | null>([
dayjs().startOf('day'),
dayjs().endOf('day'),
]);
const recordPage = reactive<Record<string, any>>({
current: 1,
records: [],
size: 20,
total: 0,
});
const recordSummary = reactive<Record<string, any>>({
endTime: '',
participantCount: 0,
startTime: '',
totalDrawTimes: 0,
totalPaidGold: 0,
totalRewardGold: 0,
});
const rewardColumns = [
{ dataIndex: 'enabled', key: 'enabled', title: '启用', width: 80 },
{ dataIndex: 'sort', key: 'sort', title: '位置', width: 90 },
{ dataIndex: 'rewardType', key: 'rewardType', title: '类型', width: 130 },
{ dataIndex: 'reward', key: 'reward', title: '奖励', width: 620 },
{ dataIndex: 'reward', key: 'reward', title: '奖励', width: 560 },
{ dataIndex: 'probability', key: 'probability', title: '概率', width: 180 },
{ dataIndex: 'limit', key: 'limit', title: '止损线', width: 280 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 90 },
];
const recordColumns = [
{ dataIndex: 'drawNo', key: 'drawNo', title: '抽奖单号', width: 190 },
{ dataIndex: 'category', key: 'category', title: '转盘', width: 120 },
{ dataIndex: 'userId', key: 'userId', title: '用户ID', width: 140 },
{ dataIndex: 'user', key: 'user', title: '用户信息', width: 280 },
{ dataIndex: 'userTotalDrawTimes', key: 'userTotalDrawTimes', title: '用户总次数', width: 130 },
{ dataIndex: 'drawTimes', key: 'drawTimes', title: '次数', width: 90 },
{ dataIndex: 'paidGold', key: 'paidGold', title: '消耗金币', width: 120 },
{ dataIndex: 'reward', key: 'reward', title: '中奖奖励', width: 360 },
@ -185,6 +223,7 @@ const recordColumns = [
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
{ dataIndex: 'createTime', key: 'createTime', title: '时间', width: 180 },
{ dataIndex: 'errorMessage', key: 'errorMessage', title: '失败原因', width: 260 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 110 },
];
const statusMeta = computed(() => {
@ -222,6 +261,14 @@ function categoryProbabilityColor(category: Record<string, any>) {
return categoryProbability(category) === PROBABILITY_TOTAL ? 'success' : 'error';
}
function probabilityPercent(value: any) {
return ((Number(value || 0) * 100) / PROBABILITY_TOTAL).toFixed(4);
}
function numberText(value: any) {
return Number(value || 0).toLocaleString();
}
function normalizeReward(item: Record<string, any> | undefined, index: number) {
const value = item || {};
const rewardType = normalizeRewardTypeValue(value.rewardType);
@ -243,6 +290,7 @@ function normalizeReward(item: Record<string, any> | undefined, index: number) {
enabled: value.enabled !== false,
goldAmount: Number(value.goldAmount || 0),
id: Number(value.id || 0),
issuedCount: Number(value.issuedCount || 0),
probability: Number(value.probability || 0),
rewardType,
resourceId: resourceId || null,
@ -251,6 +299,8 @@ function normalizeReward(item: Record<string, any> | undefined, index: number) {
resourceUrl: String(value.resourceUrl || ''),
rowKey: createRowKey(value.id || index + 1),
sort: Number(value.sort || index + 1),
totalLimit: Number(value.totalLimit || 0),
userLimit: Number(value.userLimit || 0),
};
}
@ -364,6 +414,25 @@ function normalizeRecordPage(result: null | Record<string, any> | undefined) {
recordPage.total = Number(value.total || 0);
recordPage.current = Number(value.current || 1);
recordPage.size = Number(value.size || 20);
Object.assign(recordSummary, {
endTime: String(value.endTime || ''),
participantCount: Number(value.participantCount || 0),
startTime: String(value.startTime || ''),
totalDrawTimes: Number(value.totalDrawTimes || 0),
totalPaidGold: Number(value.totalPaidGold || 0),
totalRewardGold: Number(value.totalRewardGold || 0),
});
}
function recordTimeParams() {
const range = recordRangeDate.value;
if (!range?.length) {
return { endTime: '', startTime: '' };
}
return {
endTime: range[1]?.format('YYYY-MM-DD HH:mm:ss') || '',
startTime: range[0]?.format('YYYY-MM-DD HH:mm:ss') || '',
};
}
async function loadRecords(reset = false) {
@ -377,6 +446,7 @@ async function loadRecords(reset = false) {
try {
const result = await pageResidentWheelDrawRecords({
...recordQuery,
...recordTimeParams(),
sysOrigin: selectedSysOrigin.value,
});
normalizeRecordPage(result);
@ -586,6 +656,14 @@ function validateReward(category: Record<string, any>, item: Record<string, any>
message.warning(`${label} 概率不能小于 0`);
return false;
}
if (Number(item.totalLimit || 0) < 0) {
message.warning(`${label} 总量上限不能小于 0`);
return false;
}
if (Number(item.userLimit || 0) < 0) {
message.warning(`${label} 单用户上限不能小于 0`);
return false;
}
if (
category.enabled !== false &&
item.enabled !== false &&
@ -624,7 +702,7 @@ function validateCategory(category: Record<string, any>) {
return false;
}
if (categoryProbability(category) !== PROBABILITY_TOTAL) {
message.warning(`${category.label} 启用时概率总和必须等于 10000`);
message.warning(`${category.label} 启用时概率总和必须等于 ${PROBABILITY_TOTAL}`);
return false;
}
}
@ -669,6 +747,8 @@ function categoryPayload(category: Record<string, any>) {
resourceType: rewardType === 'RESOURCE' ? String(item.resourceType || '') : '',
resourceUrl: rewardType === 'RESOURCE' ? String(item.resourceUrl || '') : '',
sort: Number(item.sort || 0),
totalLimit: Number(item.totalLimit || 0),
userLimit: Number(item.userLimit || 0),
};
}),
};
@ -715,6 +795,21 @@ function handleRecordTableChange(pagination: Record<string, any>) {
void loadRecords();
}
async function retryRecord(record: Record<string, any>) {
const id = record?.id;
if (!id) {
return;
}
retryingRecordId.value = id;
try {
await retryResidentWheelDrawRecord(id);
message.success('补发成功');
await loadRecords();
} finally {
retryingRecordId.value = null;
}
}
function rewardRecordName(record: Record<string, any>) {
if (record.resourceName) {
return record.resourceName;
@ -740,6 +835,9 @@ function statusColor(status: string) {
case 'FAILED': {
return 'error';
}
case 'PAY_FAILED': {
return 'warning';
}
case 'SUCCESS': {
return 'success';
}
@ -749,6 +847,15 @@ function statusColor(status: string) {
}
}
function userAvatarFallback(record: Record<string, any>) {
const nickname = String(record.userNickname || '').trim();
if (nickname) {
return nickname.slice(0, 1).toUpperCase();
}
const shortId = String(record.userShortId || '').trim();
return shortId ? shortId.slice(0, 1).toUpperCase() : 'U';
}
function coverStyle(url: string) {
return {
backgroundImage: `url("${String(url || '').replaceAll('"', '%22')}")`,
@ -875,7 +982,7 @@ watch(activePageTab, (value) => {
:loading="loading"
:pagination="false"
row-key="rowKey"
:scroll="{ x: 1180 }"
:scroll="{ x: 1460 }"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'enabled'">
@ -980,12 +1087,35 @@ watch(activePageTab, (value) => {
style="width: 120px"
/>
<span class="sub-text">
{{
(Number(record.probability || 0) / 100).toFixed(2)
}}%
{{ probabilityPercent(record.probability) }}%
</span>
</Space>
</template>
<template v-else-if="column.key === 'limit'">
<Space direction="vertical" size="small">
<Space align="center">
<span class="sub-text">总量</span>
<InputNumber
v-model:value="record.totalLimit"
:min="0"
:precision="0"
placeholder="不限"
style="width: 110px"
/>
<span class="sub-text">已占 {{ Number(record.issuedCount || 0) }}</span>
</Space>
<Space align="center">
<span class="sub-text">单用户</span>
<InputNumber
v-model:value="record.userLimit"
:min="0"
:precision="0"
placeholder="不限"
style="width: 110px"
/>
</Space>
</Space>
</template>
<template v-else-if="column.key === 'actions'">
<Button danger type="link" @click="clearResource(record)">
清空
@ -1018,18 +1148,44 @@ watch(activePageTab, (value) => {
allow-clear
:options="[
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAILED' },
{ label: '发奖失败', value: 'FAILED' },
{ label: '扣款失败', value: 'PAY_FAILED' },
{ label: '发奖中', value: 'GRANTING' },
{ label: '处理中', value: 'PENDING' },
]"
placeholder="状态"
style="width: 130px"
/>
<RangePicker
v-model:value="recordRangeDate"
show-time
style="width: 380px"
/>
<Button :loading="recordLoading" type="primary" @click="handleRecordSearch">
查询
</Button>
</Space>
</div>
<div class="record-summary">
<div class="record-summary-item">
<span>用户总抽奖消耗金币</span>
<strong>{{ numberText(recordSummary.totalPaidGold) }}</strong>
</div>
<div class="record-summary-item">
<span>礼物发放金币总价值</span>
<strong>{{ numberText(recordSummary.totalRewardGold) }}</strong>
</div>
<div class="record-summary-item">
<span>用户总抽奖次数</span>
<strong>{{ numberText(recordSummary.totalDrawTimes) }}</strong>
</div>
<div class="record-summary-item">
<span>用户参与量</span>
<strong>{{ numberText(recordSummary.participantCount) }}</strong>
</div>
</div>
<Table
:columns="recordColumns"
:data-source="recordPage.records"
@ -1041,13 +1197,28 @@ watch(activePageTab, (value) => {
total: recordPage.total,
}"
row-key="id"
:scroll="{ x: 1680 }"
:scroll="{ x: 2060 }"
@change="handleRecordTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'category'">
<Tag>{{ categoryLabel(record.category) }}</Tag>
</template>
<template v-else-if="column.key === 'user'">
<div class="record-user">
<Avatar :size="36" :src="record.userAvatar || undefined">
{{ userAvatarFallback(record) }}
</Avatar>
<div class="record-user-info">
<div class="record-user-name">{{ record.userNickname || '-' }}</div>
<div class="sub-text">长ID{{ record.userId || '-' }}</div>
<div class="sub-text">短ID{{ record.userShortId || '-' }}</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'userTotalDrawTimes'">
{{ numberText(record.userTotalDrawTimes) }}
</template>
<template v-else-if="column.key === 'reward'">
<div class="record-reward">
<div
@ -1065,7 +1236,7 @@ watch(activePageTab, (value) => {
</div>
</template>
<template v-else-if="column.key === 'probability'">
{{ (Number(record.probability || 0) / 100).toFixed(2) }}%
{{ probabilityPercent(record.probability) }}%
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="statusColor(record.status)">{{ record.status || '-' }}</Tag>
@ -1073,7 +1244,19 @@ watch(activePageTab, (value) => {
<template v-else-if="column.key === 'errorMessage'">
{{ record.errorMessage || '-' }}
</template>
</template>
<template v-else-if="column.key === 'actions'">
<Button
v-if="String(record.status || '').toUpperCase() === 'FAILED'"
:loading="retryingRecordId === record.id"
size="small"
type="link"
@click="retryRecord(record)"
>
补发
</Button>
<span v-else class="sub-text">-</span>
</template>
</template>
</Table>
</TabPane>
</Tabs>
@ -1087,6 +1270,7 @@ watch(activePageTab, (value) => {
"
:open="resourcePickerOpen"
:sys-origin="selectedSysOrigin"
:type-options="WHEEL_RESOURCE_TYPE_OPTIONS"
@close="closeResourcePicker"
@select="handleResourceSelect"
/>
@ -1124,12 +1308,50 @@ watch(activePageTab, (value) => {
}
.record-reward,
.record-user,
.resource-summary {
align-items: center;
display: flex;
gap: 10px;
}
.record-summary {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(160px, 1fr));
margin-bottom: 16px;
}
.record-summary-item {
background: rgb(248 250 252);
border: 1px solid rgb(226 232 240);
border-radius: 6px;
padding: 12px 14px;
}
.record-summary-item span {
color: rgb(100 116 139);
display: block;
font-size: 12px;
margin-bottom: 4px;
}
.record-summary-item strong {
color: rgb(15 23 42);
font-size: 20px;
font-weight: 700;
}
.record-user-info {
min-width: 0;
}
.record-user-name {
color: rgb(15 23 42);
font-weight: 600;
line-height: 20px;
}
.resource-summary {
margin-top: 8px;
}

View File

@ -0,0 +1,76 @@
SET NAMES utf8mb4;
SET @app_user_manager_id := (
SELECT id
FROM sys_menu
WHERE alias IN ('AppUserManager', 'OperateManager')
ORDER BY FIELD(alias, 'AppUserManager', 'OperateManager')
LIMIT 1
);
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
menu_id.next_id,
@app_user_manager_id,
'工资发放',
'operate/manual-salary-payment',
2,
'form',
'0',
'0',
49,
0,
'/manual-salary-payment',
'ManualSalaryPayment'
FROM (
SELECT COALESCE(MAX(id), 1700000000) + 1 AS next_id
FROM sys_menu
) AS menu_id
WHERE @app_user_manager_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM sys_menu WHERE alias = 'ManualSalaryPayment'
);
UPDATE sys_menu
SET
parent_id = @app_user_manager_id,
menu_name = '工资发放',
path = 'operate/manual-salary-payment',
menu_type = 2,
icon = 'form',
update_user = '0',
sort = 49,
status = 0,
router = '/manual-salary-payment'
WHERE alias = 'ManualSalaryPayment'
AND @app_user_manager_id IS NOT NULL;
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT
role_menu.role_id,
target_menu.id
FROM sys_role_menu AS role_menu
JOIN sys_menu AS source_menu
ON source_menu.id = role_menu.menu_id
AND source_menu.alias IN ('AutoSalaryPayRecord', 'AppUserManager', 'OperateManager')
JOIN sys_menu AS target_menu
ON target_menu.alias = 'ManualSalaryPayment'
WHERE NOT EXISTS (
SELECT 1
FROM sys_role_menu AS existing_role_menu
WHERE existing_role_menu.role_id = role_menu.role_id
AND existing_role_menu.menu_id = target_menu.id
);