修复时间字段

This commit is contained in:
zhx 2026-05-19 21:04:46 +08:00
parent 8d0836e7e8
commit aa0988af6b
5 changed files with 326 additions and 73 deletions

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',

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,14 +9,29 @@ 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 {
GIFT_CONFIG_TAB_OPTIONS,
GIFT_SPECIAL_OPTIONS,
@ -39,6 +39,17 @@ import {
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: '',
@ -61,17 +72,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);
@ -128,6 +128,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 +164,7 @@ watch(
Object.assign(form, {
...props.row,
explanationGift: Boolean(props.row.explanationGift),
expiredTime: normalizeTimestampValue(props.row.expiredTime),
regionList: normalizeMultiValue(props.row.regions || props.row.regionList),
specialList: normalizeMultiValue(props.row.special || props.row.specialList),
sysOrigin: props.row.sysOrigin || props.sysOrigin,
@ -290,11 +298,7 @@ async function handleSubmit() {
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');
@ -323,10 +327,11 @@ async function handleSubmit() {
<Button size="small" @click="toggleAllRegions">
{{ form.regionList.length === regions.length ? '取消全选' : '全选' }}
</Button>
<Select option-label-prop="label"
<Select
v-model:value="form.regionList"
:options="regionOptions"
mode="multiple"
option-label-prop="label"
placeholder="请选择区域"
show-search
style="width: 100%"
@ -461,14 +466,14 @@ async function handleSubmit() {
hidden
type="file"
@change="handleCoverUpload"
>
/>
<input
ref="sourceInputRef"
accept=".svga,.pag,.mp4"
hidden
type="file"
@change="handleSourceUpload"
>
/>
<template #footer>
<Space>

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

@ -17,10 +17,10 @@ import { listEmojiGroupsBySysOrigin } from '#/api/legacy/app-system';
import { listBadgePictureBySysOrigin } from '#/api/legacy/badge';
import { listGiftBySysOrigin } from '#/api/legacy/gift';
import {
listNotFamilyBySysOriginType,
listSysOriginTypeList,
saveOrUpdatePropsActivityRewardGroup,
} from '#/api/legacy/props';
import { getResidentVipConfig } from '#/api/legacy/resident-activity';
import { getDisplaySysOriginText } from '#/views/system/shared';
import {
@ -53,8 +53,11 @@ const draftLoading = ref(false);
type DraftSourceOption = {
badgeName?: string;
cover?: string;
durationDays?: number;
id: number | string;
level?: number;
name: string;
priceGold?: number;
remark?: string;
sourceUrl?: string;
};
@ -199,6 +202,35 @@ function needsRemoteOptions(type: string) {
);
}
function normalizeVipResource(value: null | Record<string, any> | undefined) {
return {
cover: String(value?.cover || value?.coverUrl || '').trim(),
sourceUrl: String(
value?.sourceUrl || value?.resourceUrl || value?.url || value?.cover || '',
).trim(),
};
}
function pickVipPreviewResource(item: Record<string, any>) {
const fields = [
'longBadge',
'shortBadge',
'avatarFrame',
'entryEffect',
'chatBubble',
'floatPicture',
'backgroundCard',
'effectImage',
];
for (const field of fields) {
const resource = normalizeVipResource(item[field]);
if (resource.cover || resource.sourceUrl) {
return resource;
}
}
return { cover: '', sourceUrl: '' };
}
async function loadOptions(type: string) {
if (!type || !form.sysOrigin || !needsRemoteOptions(type)) {
return;
@ -243,13 +275,25 @@ async function loadOptions(type: string) {
break;
}
case 'NOBLE_VIP': {
const list = await listNotFamilyBySysOriginType(form.sysOrigin, type);
store.list = list.map((item) => ({
cover: item.cover || '',
id: item.id ?? '',
name: item.name || '',
sourceUrl: item.sourceUrl || '',
}));
const config = await getResidentVipConfig(form.sysOrigin);
const levels = Array.isArray(config?.levels) ? config.levels : [];
store.list = levels
.filter((item: Record<string, any>) => item.enabled && item.id)
.map((item: Record<string, any>) => {
const preview = pickVipPreviewResource(item);
const durationDays = Number(item.durationDays || 30);
const level = Number(item.level || 0);
return {
cover: preview.cover,
durationDays,
id: item.id ?? '',
level,
name: String(item.displayName || item.levelCode || `VIP ${level}`),
priceGold: Number(item.priceGold || 0),
remark: `${durationDays}`,
sourceUrl: preview.sourceUrl,
};
});
break;
}
default: {
@ -296,6 +340,10 @@ function handleDraftContentChange(value: number | string) {
(item) => String(item.id) === String(value),
);
selectedDraftResource.value = option || null;
if (draft.type === 'NOBLE_VIP' && option) {
draft.quantity = option.durationDays || 30;
draft.remark = option.name;
}
}
function buildRewardItem(): null | RewardGroupItem {
@ -311,7 +359,7 @@ function buildRewardItem(): null | RewardGroupItem {
cover: selectedDraftResource.value?.cover || '',
detailType: draft.type,
quantity: draft.quantity,
remark: draft.remark,
remark: draft.remark || selectedDraftResource.value?.remark || selectedDraftResource.value?.name || '',
sourceUrl: selectedDraftResource.value?.sourceUrl || '',
type: mapping.value,
};
@ -370,6 +418,10 @@ function validateDraft() {
}
return true;
}
if (draft.type === 'NOBLE_VIP') {
draft.quantity = draft.quantity || selectedDraftResource.value?.durationDays || 30;
return true;
}
if (draft.quantity === '' || Number.isNaN(Number(draft.quantity))) {
message.warning('请输入数量/天数');
return false;
@ -550,6 +602,13 @@ async function handleSubmit() {
<Input v-model:value="draft.remark" placeholder="请输入描述" />
</FormItem>
<FormItem v-else-if="draft.type === 'NOBLE_VIP'" label="有效期">
<Input
:value="`${draft.quantity || selectedDraftResource?.durationDays || 30} 天`"
disabled
/>
</FormItem>
<FormItem v-else label="数量/天数">
<Input
v-model:value="draft.quantity"

View File

@ -116,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: '靓号' },
@ -318,6 +318,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}`;
}