yumi-admin/apps/src/views/gm/gold-operation.vue
2026-05-09 11:19:45 +08:00

607 lines
16 KiB
Vue

<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
DatePicker,
Form,
FormItem,
InputNumber,
message,
Modal,
Pagination,
Select,
Space,
Table,
Tag,
TextArea,
} from 'antdv-next';
import dayjs from 'dayjs';
import { submitGmGoldOperation } from '#/api/legacy/gm';
import { getClsGoldRunningWater } from '#/api/legacy/operate';
import { listMembers } from '#/api/legacy/team';
import { getUserBaseInfoBySysOriginAccount } from '#/api/legacy/user';
import AccountInput from '#/components/account-input.vue';
import UserProfileLink from '#/views/operate/components/user-profile-link.vue';
import { formatDate } from '#/views/system/shared';
defineOptions({ name: 'GmGoldOperation' });
const INCOME_REASON_OPTIONS = [
{ label: '奖励', value: 1 },
{ label: '内部', value: 2 },
{ label: '工资', value: 3 },
{ label: '充值', value: 4 },
{ label: '其他', value: 5 },
];
const DEDUCT_REASON_OPTIONS = [
{ label: '违规', value: 1 },
{ label: '多发', value: 2 },
{ label: '操作错误', value: 3 },
{ label: '其他', value: 4 },
];
const TYPE_OPTIONS = [
{ label: '增加', value: 0 },
{ label: '减少', value: 1 },
];
const DEFAULT_PAGE_SIZE = 100;
const open = ref(false);
const loading = ref(false);
const loadMoreLoading = ref(false);
const submitting = ref(false);
const list = ref<Array<Record<string, any>>>([]);
const notMore = ref(false);
const currentPage = ref(1);
const rangeDate = ref<[string, string] | null>(null);
const operators = ref<Array<Record<string, any>>>([]);
const query = reactive<Record<string, any>>({
backOperationUser: undefined,
context: '',
endTime: '',
lastId: '',
limit: DEFAULT_PAGE_SIZE,
startTime: '',
type: undefined,
userId: '',
});
const form = reactive({
operationType: 0,
quantity: undefined as number | undefined,
reasonType: 1,
remarks: '',
usdQuantity: undefined as number | undefined,
userSearch: '',
});
const columns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户', width: 240 },
{ dataIndex: 'type', key: 'type', title: '类型', width: 100 },
{ dataIndex: 'quantity', key: 'quantity', title: '金额', width: 130 },
{ dataIndex: 'balance', key: 'balance', title: '余额', width: 130 },
{ dataIndex: 'originName', key: 'originName', title: '原因', width: 220 },
{ dataIndex: 'remark', key: 'remark', title: '备注', width: 260 },
{ dataIndex: 'backOperationName', key: 'backOperationName', title: '操作人', width: 160 },
{ dataIndex: 'createTime', key: 'createTime', title: '操作时间', width: 180 },
];
const reasonOptions = computed(() =>
form.operationType === 0 ? INCOME_REASON_OPTIONS : DEDUCT_REASON_OPTIONS,
);
const showUsdQuantity = computed(
() => form.operationType === 0 && [3, 4].includes(Number(form.reasonType)),
);
const operatorOptions = computed(() =>
operators.value.map((item) => ({
label: `${item.nickname || item.loginName || item.id} / ${item.loginName || item.id}`,
value: item.id,
})),
);
const currentPageList = computed(() => {
const pageSize = Number(query.limit || DEFAULT_PAGE_SIZE);
const startIndex = (currentPage.value - 1) * pageSize;
return list.value.slice(startIndex, startIndex + pageSize);
});
const paginationTotal = computed(() => {
const pageSize = Number(query.limit || DEFAULT_PAGE_SIZE);
if (notMore.value) {
return list.value.length;
}
return Math.max(list.value.length + pageSize, (currentPage.value + 1) * pageSize);
});
watch(
rangeDate,
(value) => {
query.startTime = value?.[0] || '';
query.endTime = value?.[1] || '';
},
{ immediate: true },
);
watch(
() => form.operationType,
() => {
form.reasonType = reasonOptions.value[0]?.value || 1;
form.usdQuantity = undefined;
},
);
watch(showUsdQuantity, (value) => {
if (!value) {
form.usdQuantity = undefined;
}
});
function createDefaultRange() {
const end = dayjs().endOf('day');
const start = dayjs().subtract(1, 'day').startOf('day');
return [String(start.valueOf()), String(end.valueOf())] as [string, string];
}
function applyRangeDate(value: [string, string] | null) {
rangeDate.value = value;
query.startTime = value?.[0] || '';
query.endTime = value?.[1] || '';
}
function resetForm() {
form.operationType = 0;
form.quantity = undefined;
form.reasonType = 1;
form.remarks = '';
form.usdQuantity = undefined;
form.userSearch = '';
}
function parseEncodedValue(value: unknown) {
const raw = String(value ?? '');
const match = raw.match(/^\*(.*)\*$/);
if (!match) {
return {
accountType: '',
content: raw,
sysOrigin: '',
};
}
const body = match[1] || '';
return {
accountType: body.match(/accountType:([^,]*)/)?.[1] || '',
content: body.match(/content:(.*)$/)?.[1] || '',
sysOrigin: body.match(/sysOrigin:([^,]*)/)?.[1] || '',
};
}
async function resolveUserId() {
const parsed = parseEncodedValue(form.userSearch);
const content = String(parsed.content || '').trim();
if (!content) {
message.warning('请输入用户ID');
return '';
}
if (!parsed.accountType || parsed.accountType === 'LONG') {
return content.replace(/[^\d]/g, '');
}
if (!parsed.sysOrigin) {
message.warning('请选择系统');
return '';
}
const result = await getUserBaseInfoBySysOriginAccount(parsed.sysOrigin, content);
const userId = result?.id || result?.userId;
if (!userId) {
message.warning('未找到用户');
return '';
}
return String(userId);
}
function validateForm() {
if (!String(form.userSearch || '').trim()) {
message.warning('请输入用户ID');
return false;
}
if (!form.quantity || Number(form.quantity) <= 0) {
message.warning('请输入大于0的金币数量');
return false;
}
if (!String(form.remarks || '').trim()) {
message.warning('请输入备注');
return false;
}
return true;
}
function formatRunningWaterTime(value: any) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
return formatDate(Number(trimmed));
}
}
return formatDate(value);
}
function getRunningWater(record: Record<string, any>) {
return record.runningWater || record;
}
function getRowKey(record: Record<string, any>) {
return getRunningWater(record).id || record.id;
}
function getRecordType(record: Record<string, any>) {
return Number(getRunningWater(record).type);
}
function getAmountPrefix(record: Record<string, any>) {
return getRecordType(record) === 0 ? '+' : '-';
}
function getAmountClass(record: Record<string, any>) {
return getRecordType(record) === 0 ? 'amount--income' : 'amount--expense';
}
function filterOperatorOption(input: string, option: any) {
return String(option?.label || '').toLowerCase().includes(input.toLowerCase());
}
async function loadOperators() {
operators.value = (await listMembers()) || [];
}
async function loadData(reset = false) {
if (loading.value || loadMoreLoading.value) {
return;
}
if (!query.startTime || !query.endTime) {
message.warning('请选择时间范围');
return;
}
const startYear = new Date(Number(query.startTime)).getFullYear();
const endYear = new Date(Number(query.endTime)).getFullYear();
if (startYear !== endYear) {
message.warning('不允许跨年查询');
return;
}
if (reset) {
list.value = [];
notMore.value = false;
query.context = '';
query.lastId = '';
currentPage.value = 1;
}
if (reset) {
loading.value = true;
} else {
loadMoreLoading.value = true;
}
try {
const result = await getClsGoldRunningWater({
backOperationUser: query.backOperationUser || undefined,
context: query.context || undefined,
endTime: query.endTime,
lastId: query.lastId || undefined,
limit: query.limit,
queryBackOperationUser: true,
startTime: query.startTime,
type: query.type,
userId: query.userId || undefined,
});
const waters = result?.waters || [];
const nextList = reset ? waters : [...list.value, ...waters];
list.value = nextList;
query.context = result?.context || '';
query.lastId = String(getRunningWater(nextList[nextList.length - 1] || {}).id || '');
notMore.value =
Boolean(result?.listOver) ||
(!query.context && waters.length < Number(query.limit || DEFAULT_PAGE_SIZE));
} finally {
loading.value = false;
loadMoreLoading.value = false;
}
}
async function ensurePageLoaded(page: number) {
const pageSize = Number(query.limit || DEFAULT_PAGE_SIZE);
let guard = 0;
while (!notMore.value && list.value.length < page * pageSize && guard < page) {
const beforeLength = list.value.length;
const beforeContext = query.context;
await loadData(false);
guard += 1;
if (list.value.length === beforeLength && query.context === beforeContext) {
break;
}
}
}
async function handlePageChange(page: number, pageSize?: number) {
if (pageSize && pageSize !== Number(query.limit || DEFAULT_PAGE_SIZE)) {
query.limit = pageSize;
await loadData(true);
return;
}
await ensurePageLoaded(page);
if (notMore.value) {
const maxPage = Math.max(1, Math.ceil(list.value.length / Number(query.limit || DEFAULT_PAGE_SIZE)));
currentPage.value = Math.min(page, maxPage);
return;
}
currentPage.value = page;
}
async function handleSearch() {
await loadData(true);
}
async function handleSubmit() {
if (!validateForm()) {
return;
}
const userId = await resolveUserId();
if (!userId) {
return;
}
submitting.value = true;
try {
const result = await submitGmGoldOperation({
operationType: form.operationType,
quantity: form.quantity,
reasonType: form.reasonType,
remarks: form.remarks,
usdQuantity: showUsdQuantity.value ? form.usdQuantity : undefined,
userId,
});
if (result?.status === 'PENDING') {
message.success('已提交 yumiadmin 审核');
} else {
message.success('金币操作已执行');
}
await loadData(true);
open.value = false;
resetForm();
} finally {
submitting.value = false;
}
}
applyRangeDate(createDefaultRange());
void loadOperators();
void loadData(true);
</script>
<template>
<Page title="金币操作">
<Card>
<div class="page-head">
<div>
<div class="page-head__title">后台金币增减记录</div>
<div class="page-head__desc">查询所有管理员给用户发放或扣除金币的操作流水</div>
</div>
<Button type="primary" @click="open = true">金币操作</Button>
</div>
<div class="toolbar">
<DatePicker.RangePicker
v-model:value="rangeDate"
show-time
style="width: 360px"
value-format="x"
/>
<Select
v-model:value="query.type"
allow-clear
placeholder="类型"
style="width: 140px"
:options="TYPE_OPTIONS"
/>
<AccountInput
v-model:value="query.userId"
default-select-type="LONG"
placeholder="用户ID"
style="width: 240px"
/>
<Select
v-model:value="query.backOperationUser"
allow-clear
show-search
placeholder="操作人"
style="width: 220px"
:filter-option="filterOperatorOption"
:options="operatorOptions"
/>
<Button :loading="loading" type="primary" @click="handleSearch">查询</Button>
</div>
<Table
:columns="columns"
:data-source="currentPageList"
:loading="loading"
:pagination="false"
:row-key="getRowKey"
:scroll="{ x: 1420 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
</template>
<template v-else-if="column.key === 'type'">
<Tag :color="getRecordType(record) === 0 ? 'green' : 'red'">
{{ getRecordType(record) === 0 ? '增加' : '减少' }}
</Tag>
</template>
<template v-else-if="column.key === 'quantity'">
<span :class="['amount', getAmountClass(record)]">
{{ getAmountPrefix(record) }}{{ getRunningWater(record).quantity ?? 0 }}
</span>
</template>
<template v-else-if="column.key === 'balance'">
{{ getRunningWater(record).balance ?? '-' }}
</template>
<template v-else-if="column.key === 'originName'">
{{ getRunningWater(record).originName || '-' }}
</template>
<template v-else-if="column.key === 'remark'">
{{ getRunningWater(record).remark || '-' }}
</template>
<template v-else-if="column.key === 'backOperationName'">
{{ record.backOperationName || '-' }}
</template>
<template v-else-if="column.key === 'createTime'">
{{ formatRunningWaterTime(getRunningWater(record).createTime) }}
</template>
</template>
</Table>
<div v-if="list.length > 0" class="pagination">
<Pagination
:current="currentPage"
:disabled="loading || loadMoreLoading"
:page-size="query.limit"
:page-size-options="['20', '50', '100', '200']"
:show-size-changer="true"
:total="paginationTotal"
show-less-items
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
<Modal
v-model:open="open"
title="金币操作"
:confirm-loading="submitting"
width="560px"
@cancel="resetForm"
@ok="handleSubmit"
>
<Form layout="vertical">
<FormItem label="用户ID">
<AccountInput
v-model:value="form.userSearch"
default-select-type="LONG"
placeholder="请输入长ID或短ID"
/>
</FormItem>
<FormItem label="操作类型">
<Select
v-model:value="form.operationType"
:options="[
{ label: '增加金币', value: 0 },
{ label: '扣除金币', value: 1 },
]"
/>
</FormItem>
<Space class="form-row" :size="12">
<FormItem class="form-row__item" label="金币数量">
<InputNumber
v-model:value="form.quantity"
:min="0.01"
:precision="2"
class="full-input"
/>
</FormItem>
<FormItem class="form-row__item" label="原因">
<Select v-model:value="form.reasonType" :options="reasonOptions" />
</FormItem>
</Space>
<FormItem v-if="showUsdQuantity" label="折算USD金额">
<InputNumber
v-model:value="form.usdQuantity"
:min="0"
:precision="2"
class="full-input"
/>
</FormItem>
<FormItem label="备注">
<TextArea
v-model:value="form.remarks"
:maxlength="500"
:rows="4"
show-count
placeholder="请输入操作原因和备注"
/>
</FormItem>
</Form>
</Modal>
</Page>
</template>
<style scoped>
.page-head {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head__title {
color: #111827;
font-size: 16px;
font-weight: 600;
}
.page-head__desc {
color: #6b7280;
font-size: 13px;
margin-top: 4px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.amount {
font-weight: 600;
}
.amount--income {
color: #16a34a;
}
.amount--expense {
color: #dc2626;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.form-row {
display: flex;
width: 100%;
}
.form-row__item {
flex: 1;
}
.full-input {
width: 100%;
}
</style>