金币权限

This commit is contained in:
170-carry 2026-04-30 18:59:28 +08:00
parent a38e98e29e
commit 9dbb25287d
10 changed files with 1440 additions and 71 deletions

23
apps/src/api/legacy/gm.ts Normal file
View File

@ -0,0 +1,23 @@
import type { LegacyPageResult } from '#/api/legacy/system';
import { requestClient } from '#/api/request';
export async function submitGmGoldOperation(data: Record<string, any>) {
return requestClient.post<Record<string, any>>('/gm/gold-operation', data);
}
export async function pageGmGoldOperation(params: Record<string, any>) {
return requestClient.get<LegacyPageResult<Record<string, any>>>(
'/gm/gold-operation/page',
{
params,
},
);
}
export async function reviewGmGoldOperation(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
'/gm/gold-operation/review',
data,
);
}

View File

@ -106,6 +106,16 @@ export async function updatePayFactory(data: Record<string, any>) {
return requestClient.post('/sys-pay-factory/update', data);
}
export async function getPayFactoryConfig(params: Record<string, any>) {
return requestClient.get<Record<string, any>>('/sys-pay-factory/config', {
params,
});
}
export async function savePayFactoryConfig(data: Record<string, any>) {
return requestClient.post('/sys-pay-factory/config/save', data);
}
export async function addPayFactoryAssociatedChannels(
data: Array<Record<string, any>>,
) {

View File

@ -0,0 +1,30 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:shield',
order: 49.4,
title: 'GM操作',
},
name: 'GmOperation',
path: '/gm/operation',
redirect: '/gm/operation/gold-operation',
children: [
{
name: 'GmGoldOperation',
path: 'gold-operation',
component: () => import('#/views/gm/gold-operation.vue'),
meta: { title: '金币操作' },
},
{
name: 'GmGoldOperationReview',
path: 'gold-operation-review',
component: () => import('#/views/gm/gold-operation-review.vue'),
meta: { title: '金币操作审核' },
},
],
},
];
export default routes;

View File

@ -0,0 +1,305 @@
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
DatePicker,
Input,
message,
Modal,
Pagination,
Select,
Space,
Table,
Tag,
} from 'antdv-next';
import {
pageGmGoldOperation,
reviewGmGoldOperation,
} from '#/api/legacy/gm';
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,
getDisplaySysOriginText,
} from '#/views/system/shared';
defineOptions({ name: 'GmGoldOperationReview' });
const loading = ref(false);
const reviewing = ref(false);
const list = ref<Array<Record<string, any>>>([]);
const total = ref(0);
const rangeDate = ref<[string, string] | null>(null);
const userSearch = ref('');
const query = reactive({
applicantId: '',
endTime: '',
operationType: undefined as number | undefined,
pageIndex: 1,
pageSize: 20,
startTime: '',
status: 'PENDING',
userId: '',
});
const columns = [
{ dataIndex: 'userProfile', key: 'userProfile', title: '用户', width: 240 },
{ dataIndex: 'sysOrigin', key: 'sysOrigin', title: '系统', width: 100 },
{ dataIndex: 'operationType', key: 'operationType', title: '类型', width: 100 },
{ dataIndex: 'quantity', key: 'quantity', title: '金币数量', width: 120 },
{ dataIndex: 'reasonName', key: 'reasonName', title: '原因', width: 120 },
{ dataIndex: 'remarks', key: 'remarks', title: '备注', width: 220 },
{ dataIndex: 'applicant', key: 'applicant', title: '申请人', width: 160 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 110 },
{ dataIndex: 'reviewer', key: 'reviewer', title: '审核人', width: 160 },
{ dataIndex: 'time', key: 'time', title: '时间', width: 240 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 160 },
];
watch(rangeDate, (value) => {
query.startTime = value?.[0] || '';
query.endTime = value?.[1] || '';
});
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(userSearch.value);
const content = String(parsed.content || '').trim();
if (!content) {
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);
}
async function loadData(reset = false) {
if (reset) {
query.pageIndex = 1;
}
loading.value = true;
try {
query.userId = await resolveUserId();
const result = await pageGmGoldOperation({
...query,
applicantId: query.applicantId || undefined,
operationType: query.operationType,
userId: query.userId || undefined,
});
list.value = result.records || [];
total.value = result.total || 0;
} finally {
loading.value = false;
}
}
function handlePageChange(page: number, pageSize: number) {
query.pageIndex = page;
query.pageSize = pageSize;
void loadData();
}
function statusColor(status?: string) {
if (status === 'APPROVED') {
return 'success';
}
if (status === 'REJECTED') {
return 'error';
}
return 'warning';
}
function handleReview(record: Record<string, any>, approved: boolean) {
Modal.confirm({
title: approved ? '确认通过该金币操作?' : '确认拒绝该金币操作?',
okText: approved ? '通过' : '拒绝',
okType: approved ? 'primary' : 'danger',
async onOk() {
reviewing.value = true;
try {
await reviewGmGoldOperation({
approved,
id: record.id,
reviewRemark: approved ? '同意' : '拒绝',
});
message.success(approved ? '已通过并执行金币操作' : '已拒绝');
await loadData();
} finally {
reviewing.value = false;
}
},
});
}
void loadData(true);
</script>
<template>
<Page title="金币操作审核">
<Card>
<div class="toolbar">
<Select
v-model:value="query.status"
allow-clear
placeholder="状态"
style="width: 140px"
:options="[
{ label: '待审核', value: 'PENDING' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已拒绝', value: 'REJECTED' },
]"
/>
<Select
v-model:value="query.operationType"
allow-clear
placeholder="操作类型"
style="width: 140px"
:options="[
{ label: '增加金币', value: 0 },
{ label: '扣除金币', value: 1 },
]"
/>
<AccountInput
v-model:value="userSearch"
default-select-type="LONG"
placeholder="用户ID"
style="width: 260px"
/>
<Input
v-model:value="query.applicantId"
allow-clear
placeholder="申请人后台ID"
style="width: 160px"
/>
<DatePicker.RangePicker
v-model:value="rangeDate"
show-time
style="width: 360px"
value-format="x"
/>
<Button :loading="loading" type="primary" @click="loadData(true)">
搜索
</Button>
</div>
<Table
:columns="columns"
:data-source="list"
:loading="loading || reviewing"
:pagination="false"
row-key="id"
:scroll="{ x: 1650 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userProfile'">
<UserProfileLink :profile="record.userProfile" />
</template>
<template v-else-if="column.key === 'sysOrigin'">
{{ getDisplaySysOriginText(record.sysOrigin) }}
</template>
<template v-else-if="column.key === 'operationType'">
<Tag :color="record.operationType === 0 ? 'red' : 'default'">
{{ record.operationName }}
</Tag>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="statusColor(record.status)">
{{ record.statusName }}
</Tag>
</template>
<template v-else-if="column.key === 'applicant'">
<div>{{ record.applicantName || '-' }}</div>
<div class="muted">{{ record.applicantId || '-' }}</div>
</template>
<template v-else-if="column.key === 'reviewer'">
<div>{{ record.reviewerName || '-' }}</div>
<div class="muted">{{ record.reviewerId || '-' }}</div>
</template>
<template v-else-if="column.key === 'time'">
<div>申请{{ formatDate(record.createTime) }}</div>
<div>审核{{ formatDate(record.reviewTime) }}</div>
<div>执行{{ formatDate(record.executedTime) }}</div>
</template>
<template v-else-if="column.key === 'actions'">
<Space v-if="record.status === 'PENDING'">
<Button size="small" type="link" @click="handleReview(record, true)">
通过
</Button>
<Button danger size="small" type="link" @click="handleReview(record, false)">
拒绝
</Button>
</Space>
<span v-else>-</span>
</template>
</template>
</Table>
<div class="pager">
<Pagination
:current="query.pageIndex"
:page-size="query.pageSize"
:total="total"
show-size-changer
@change="handlePageChange"
@showSizeChange="handlePageChange"
/>
</div>
</Card>
</Page>
</template>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.muted {
color: #8c8c8c;
font-size: 12px;
margin-top: 2px;
}
</style>

View File

@ -0,0 +1,590 @@
<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: '',
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 = '';
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,
limit: query.limit,
queryBackOperationUser: true,
startTime: query.startTime,
type: query.type,
userId: query.userId || undefined,
});
const waters = result?.waters || [];
list.value = reset ? waters : [...list.value, ...waters];
query.context = result?.context || '';
notMore.value = Boolean(result?.listOver) || !query.context;
} 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) {
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"
:total="paginationTotal"
show-less-items
@change="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>

View File

@ -0,0 +1,231 @@
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import SysOriginSelect from '#/components/sys-origin-select.vue';
import {
getPayFactoryConfig,
savePayFactoryConfig,
} from '#/api/legacy/pay';
import {
Button,
Form,
FormItem,
Input,
Modal,
Select,
Switch,
TextArea,
message,
} from 'antdv-next';
const props = withDefaults(
defineProps<{
open: boolean;
row?: Record<string, any>;
}>(),
{
row: () => ({}),
},
);
const emit = defineEmits<{
close: [];
success: [];
}>();
const loading = ref(false);
const saving = ref(false);
const form = reactive<Record<string, any>>({
enabled: true,
env: 'PROD',
factoryCode: '',
gatewayBaseUrl: '',
id: '',
merAccount: '',
merNo: '',
notifyUrl: '',
platformRsaPublicKey: '',
rsaPrivateKey: '',
sysOrigin: '',
});
const envOptions = [
{ label: 'PROD', value: 'PROD' },
{ label: 'TEST', value: 'TEST' },
{ label: '通用', value: '' },
];
watch(
() => props.open,
(open) => {
if (!open) {
return;
}
const row = props.row || {};
Object.assign(form, {
enabled: true,
env: 'PROD',
factoryCode: row.factoryCode || '',
gatewayBaseUrl: '',
id: '',
merAccount: '',
merNo: '',
notifyUrl: '',
platformRsaPublicKey: '',
rsaPrivateKey: '',
sysOrigin: '',
});
if (form.factoryCode) {
void loadConfig();
}
},
{ immediate: true },
);
function resetConfigFields() {
Object.assign(form, {
enabled: true,
gatewayBaseUrl: '',
id: '',
merAccount: '',
merNo: '',
notifyUrl: '',
platformRsaPublicKey: '',
rsaPrivateKey: '',
});
}
async function loadConfig() {
if (!form.factoryCode) {
return;
}
loading.value = true;
try {
const scope = {
env: form.env || '',
factoryCode: form.factoryCode,
sysOrigin: form.sysOrigin || '',
};
const result = await getPayFactoryConfig(scope);
resetConfigFields();
Object.assign(form, scope, result || {});
} finally {
loading.value = false;
}
}
function validateForm() {
const requiredFields: Array<[string, string]> = [
['merAccount', '请输入商户标识'],
['merNo', '请输入商户编号'],
['gatewayBaseUrl', '请输入网关地址'],
['rsaPrivateKey', '请输入商户私钥'],
['platformRsaPublicKey', '请输入平台公钥'],
['notifyUrl', '请输入回调地址'],
];
for (const [key, text] of requiredFields) {
if (!String(form[key] || '').trim()) {
message.warning(text);
return false;
}
}
return true;
}
async function handleSubmit() {
if (!validateForm()) {
return;
}
saving.value = true;
try {
await savePayFactoryConfig({ ...form });
message.success('保存成功');
emit('success');
emit('close');
} finally {
saving.value = false;
}
}
</script>
<template>
<Modal
:confirm-loading="saving"
:open="open"
destroy-on-close
title="MiFaPay 配置"
width="760px"
@cancel="emit('close')"
@ok="handleSubmit"
>
<Form layout="vertical">
<div class="scope-row">
<FormItem label="厂商Code">
<Input v-model:value="form.factoryCode" disabled />
</FormItem>
<FormItem label="环境">
<Select
v-model:value="form.env"
:options="envOptions"
@change="loadConfig"
/>
</FormItem>
<FormItem label="来源系统">
<SysOriginSelect
v-model:value="form.sysOrigin"
allow-clear
placeholder="通用"
@change="loadConfig"
/>
</FormItem>
<FormItem label=" ">
<Button :loading="loading" @click="loadConfig">读取配置</Button>
</FormItem>
</div>
<div class="grid">
<FormItem label="商户标识 merAccount">
<Input v-model:value="form.merAccount" />
</FormItem>
<FormItem label="商户编号 merNo">
<Input v-model:value="form.merNo" />
</FormItem>
</div>
<FormItem label="网关地址 gatewayBaseUrl">
<Input v-model:value="form.gatewayBaseUrl" />
</FormItem>
<FormItem label="商户RSA私钥">
<TextArea v-model:value="form.rsaPrivateKey" :rows="5" />
</FormItem>
<FormItem label="平台RSA公钥">
<TextArea v-model:value="form.platformRsaPublicKey" :rows="5" />
</FormItem>
<FormItem label="回调地址 notifyUrl">
<Input v-model:value="form.notifyUrl" />
</FormItem>
<FormItem label="启用">
<Switch v-model:checked="form.enabled" />
</FormItem>
</Form>
</Modal>
</template>
<style scoped>
.grid {
column-gap: 12px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.scope-row {
column-gap: 12px;
display: grid;
grid-template-columns: 180px 130px minmax(180px, 1fr) 96px;
}
</style>

View File

@ -16,6 +16,7 @@ import {
} from 'antdv-next';
import PayFactoryAssociatedModal from './components/pay-factory-associated-modal.vue';
import PayFactoryConfigModal from './components/pay-factory-config-modal.vue';
import PayFactoryEditModal from './components/pay-factory-edit-modal.vue';
defineOptions({ name: 'OperatePayFactory' });
@ -25,6 +26,7 @@ const total = ref(0);
const list = ref<Array<Record<string, any>>>([]);
const formOpen = ref(false);
const associatedOpen = ref(false);
const configOpen = ref(false);
const activeRow = ref<Record<string, any>>({});
const query = reactive<Record<string, any>>({
@ -39,7 +41,7 @@ const columns: any[] = [
{ dataIndex: 'factoryName', key: 'factoryName', title: '名称', width: 220 },
{ dataIndex: 'factoryIcon', key: 'factoryIcon', title: 'Icon', width: 120 },
{ dataIndex: 'createTime', key: 'createTime', title: '创建时间', width: 180 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 140 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 220 },
];
async function loadData(reset = false) {
@ -81,6 +83,11 @@ function handleAssociated(record: Record<string, any>) {
associatedOpen.value = true;
}
function handleConfig(record: Record<string, any>) {
activeRow.value = { ...record };
configOpen.value = true;
}
void loadData(true);
</script>
@ -126,6 +133,7 @@ void loadData(true);
<Button type="link" @click="handleAssociated(record)">
关联渠道
</Button>
<Button type="link" @click="handleConfig(record)">配置</Button>
<Button type="link" @click="handleEdit(record)">编辑</Button>
</template>
</template>
@ -155,6 +163,12 @@ void loadData(true);
:open="associatedOpen"
@close="associatedOpen = false"
/>
<PayFactoryConfigModal
:open="configOpen"
:row="activeRow"
@close="configOpen = false"
/>
</Page>
</template>

View File

@ -46,7 +46,6 @@ import {
USER_TYPE_OPTIONS,
} from './constants';
import UserAuthInfoDrawer from './components/user-auth-info-drawer.vue';
import UserBalanceHandleModal from './components/user-balance-handle-modal.vue';
import UserResetPasswordModal from './components/user-reset-password-modal.vue';
import {
getUserProfileAvatar,
@ -98,12 +97,9 @@ const accountLogOpen = ref(false);
const violationOpen = ref(false);
const authInfoOpen = ref(false);
const resetPasswordOpen = ref(false);
const balanceOpen = ref(false);
const activeUserId = ref<number | string>('');
const activeUserRecord = ref<Record<string, any> | null>(null);
const balanceAction = ref<'deduct' | 'reward'>('reward');
const balanceType = ref<'diamond' | 'gameCoupon' | 'gold'>('gold');
const query = reactive(createQuery());
@ -120,7 +116,7 @@ const columns = [
{ dataIndex: 'accountStatus', key: 'accountStatus', title: '账户', width: 120 },
{ dataIndex: 'registerSource', key: 'registerSource', title: '注册来源', width: 180 },
{ dataIndex: 'time', key: 'time', title: '时间', width: 220 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 320 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 260 },
];
const filteredCountries = computed(() => {
@ -295,22 +291,11 @@ function openResetPassword(record: Record<string, any>) {
resetPasswordOpen.value = true;
}
function openBalanceModal(
record: Record<string, any>,
type: 'diamond' | 'gameCoupon' | 'gold',
action: 'deduct' | 'reward',
) {
activeUserId.value = record.userProfile?.id || '';
balanceType.value = type;
balanceAction.value = action;
balanceOpen.value = true;
}
function buildEncodedAccount(sysOrigin: string, userId: number | string) {
return `*type:USER,accountType:LONG,sysOrigin:${sysOrigin},content:${userId}*`;
}
function openRunningWater(record: Record<string, any>, kind: 'diamond' | 'gameCoupon' | 'gold') {
function openRunningWater(record: Record<string, any>, kind: 'diamond' | 'gold') {
const userId = record.userProfile?.id;
if (!userId) {
return;
@ -333,12 +318,7 @@ function openRunningWater(record: Record<string, any>, kind: 'diamond' | 'gameCo
path: '/user/diamond-run-water',
query: { sysOrigin, userId: encoded },
});
return;
}
router.push({
path: '/game/coupon/running/water',
query: { userId: encoded },
});
}
async function copyUserId(value?: number | string) {
@ -515,18 +495,6 @@ void loadCountries();
<Button size="small" type="link" @click="openAccountLog(record)">
处理记录
</Button>
<Button size="small" type="link" @click="openBalanceModal(record, 'gold', 'reward')">
金币奖励
</Button>
<Button size="small" type="link" @click="openBalanceModal(record, 'gold', 'deduct')">
金币扣除
</Button>
<Button size="small" type="link" @click="openBalanceModal(record, 'diamond', 'reward')">
钻石奖励
</Button>
<Button size="small" type="link" @click="openBalanceModal(record, 'diamond', 'deduct')">
钻石扣除
</Button>
<Button size="small" type="link" @click="openAuthInfo(record)">
认证信息
</Button>
@ -538,14 +506,6 @@ void loadCountries();
>
重置密码
</Button>
<Button
v-if="activeTab === 'table'"
size="small"
type="link"
@click="openRunningWater(record, 'gameCoupon')"
>
游戏券收支
</Button>
</Space>
</template>
</template>
@ -599,14 +559,6 @@ void loadCountries();
@close="resetPasswordOpen = false"
@success="resetPasswordOpen = false"
/>
<UserBalanceHandleModal
:action="balanceAction"
:open="balanceOpen"
:type="balanceType"
:user-id="activeUserId"
@close="balanceOpen = false"
@success="balanceOpen = false; loadData()"
/>
</Page>
</template>

View File

@ -48,7 +48,7 @@ const fullscreen = ref(false);
const visualMode = ref(false);
const loading = ref(false);
const dashboard = ref<CountryDashboardResult | null>(null);
const upDashboards = ref<{
const arpuDashboards = ref<{
day: CountryDashboardResult | null;
threeDay: CountryDashboardResult | null;
week: CountryDashboardResult | null;
@ -282,12 +282,12 @@ const appDataCards = computed(() => {
{ label: '30 日留存率', value: formatPercent(summary.d30RetentionRate) },
];
});
const upValueCards = computed(() => {
const arpuCards = computed(() => {
return [
{ label: '日 UP 值', value: formatUpValue(upDashboards.value.day) },
{ label: '3 日 UP 值', value: formatUpValue(upDashboards.value.threeDay) },
{ label: '周 UP 值', value: formatUpValue(upDashboards.value.week) },
{ label: '筛选 UP 值', value: formatAmount(calcUpValue(visibleSummary.value)) },
{ label: '日 ARPU', value: formatArpu(arpuDashboards.value.day) },
{ label: '3 日 ARPU', value: formatArpu(arpuDashboards.value.threeDay) },
{ label: '周 ARPU', value: formatArpu(arpuDashboards.value.week) },
{ label: '筛选 ARPU', value: formatAmount(calcArpu(visibleSummary.value)) },
];
});
@ -372,7 +372,7 @@ async function loadDashboard() {
}
loading.value = true;
try {
const [mainDashboard, dayUpDashboard, threeDayUpDashboard, weekUpDashboard] =
const [mainDashboard, dayArpuDashboard, threeDayArpuDashboard, weekArpuDashboard] =
await Promise.all([
countryDashboard(buildParams()),
countryDashboard(buildRecentDayParams(1)),
@ -380,14 +380,14 @@ async function loadDashboard() {
countryDashboard(buildRecentDayParams(7)),
]);
dashboard.value = mainDashboard;
upDashboards.value = {
day: dayUpDashboard,
threeDay: threeDayUpDashboard,
week: weekUpDashboard,
arpuDashboards.value = {
day: dayArpuDashboard,
threeDay: threeDayArpuDashboard,
week: weekArpuDashboard,
};
} catch (error) {
dashboard.value = null;
upDashboards.value = {
arpuDashboards.value = {
day: null,
threeDay: null,
week: null,
@ -495,7 +495,7 @@ function resolveMetricRate(record: Record<string, any>, key: string) {
return 0;
}
function calcUpValue(summary: Record<string, any>) {
function calcArpu(summary: Record<string, any>) {
const activeUser = toAmount(summary.dailyActiveUser);
if (activeUser === 0) {
return 0;
@ -503,9 +503,9 @@ function calcUpValue(summary: Record<string, any>) {
return toAmount(summary.totalRecharge) / activeUser;
}
function formatUpValue(result?: CountryDashboardResult | null) {
function formatArpu(result?: CountryDashboardResult | null) {
const summary = buildVisibleSummary(filterRecordsBySelectedCountries(result?.records || []));
return formatAmount(calcUpValue(summary));
return formatAmount(calcArpu(summary));
}
function emptySummary(): SummaryMetric {
@ -887,10 +887,10 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="panel metric-section">
<div class="metric-section__title">UP </div>
<div class="metric-card-grid metric-card-grid--up">
<div class="metric-section__title">ARPU</div>
<div class="metric-card-grid metric-card-grid--arpu">
<div
v-for="item in upValueCards"
v-for="item in arpuCards"
:key="item.label"
class="metric-card"
>
@ -1074,7 +1074,7 @@ onBeforeUnmount(() => {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card-grid--up {
.metric-card-grid--arpu {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -1195,7 +1195,7 @@ onBeforeUnmount(() => {
.metric-section-grid,
.metric-card-grid,
.metric-card-grid--app,
.metric-card-grid--up {
.metric-card-grid--arpu {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,214 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `gm_gold_operation_record` (
`id` bigint NOT NULL COMMENT '记录ID',
`user_id` bigint NOT NULL COMMENT 'APP用户ID',
`sys_origin` varchar(32) DEFAULT NULL COMMENT '系统来源',
`operation_type` tinyint NOT NULL COMMENT '操作类型:0增加,1扣除',
`quantity` decimal(20,2) NOT NULL COMMENT '金币数量',
`reason_type` tinyint NOT NULL COMMENT '原因类型',
`usd_quantity` decimal(20,2) DEFAULT NULL COMMENT '工资/充值折算USD金额',
`remarks` varchar(500) NOT NULL COMMENT '备注',
`status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待审核,APPROVED已通过,REJECTED已拒绝',
`applicant_id` bigint NOT NULL COMMENT '申请后台用户ID',
`applicant_name` varchar(100) DEFAULT NULL COMMENT '申请后台用户名称',
`reviewer_id` bigint DEFAULT NULL COMMENT '审核后台用户ID',
`reviewer_name` varchar(100) DEFAULT NULL COMMENT '审核后台用户名称',
`review_remark` varchar(500) DEFAULT NULL COMMENT '审核备注',
`track_id` varchar(64) NOT NULL COMMENT '钱包流水跟踪ID',
`review_time` datetime DEFAULT NULL COMMENT '审核时间',
`executed_time` datetime DEFAULT NULL COMMENT '执行时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`create_user` bigint DEFAULT NULL COMMENT '创建用户',
`update_user` bigint DEFAULT NULL COMMENT '更新用户',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_gm_gold_track_id` (`track_id`),
KEY `idx_gm_gold_status_time` (`status`, `create_time`),
KEY `idx_gm_gold_user` (`user_id`),
KEY `idx_gm_gold_applicant` (`applicant_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='GM金币操作申请记录';
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
1700000400,
0,
'GM操作',
'/gm/operation',
1,
'lucide:shield',
'0',
'0',
48,
0,
'/gm/operation',
'GmOperation'
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE alias = 'GmOperation'
);
UPDATE sys_menu
SET
parent_id = 0,
menu_name = 'GM操作',
path = '/gm/operation',
menu_type = 1,
icon = 'lucide:shield',
create_user = '0',
update_user = '0',
sort = 48,
status = 0,
router = '/gm/operation'
WHERE alias = 'GmOperation';
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
1700000401,
(SELECT id FROM (SELECT id FROM sys_menu WHERE alias = 'GmOperation' LIMIT 1) AS parent_menu),
'金币操作',
'gm/gold-operation',
2,
'lucide:coins',
'0',
'0',
1,
0,
'gold-operation',
'GmGoldOperation'
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE alias = 'GmGoldOperation'
);
UPDATE sys_menu
SET
parent_id = (SELECT id FROM (SELECT id FROM sys_menu WHERE alias = 'GmOperation' LIMIT 1) AS parent_menu),
menu_name = '金币操作',
path = 'gm/gold-operation',
menu_type = 2,
icon = 'lucide:coins',
create_user = '0',
update_user = '0',
sort = 1,
status = 0,
router = 'gold-operation'
WHERE alias = 'GmGoldOperation';
INSERT INTO sys_menu (
id,
parent_id,
menu_name,
path,
menu_type,
icon,
create_user,
update_user,
sort,
status,
router,
alias
)
SELECT
1700000402,
(SELECT id FROM (SELECT id FROM sys_menu WHERE alias = 'GmOperation' LIMIT 1) AS parent_menu),
'金币操作审核',
'gm/gold-operation-review',
2,
'lucide:badge-check',
'0',
'0',
2,
0,
'gold-operation-review',
'GmGoldOperationReview'
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE alias = 'GmGoldOperationReview'
);
UPDATE sys_menu
SET
parent_id = (SELECT id FROM (SELECT id FROM sys_menu WHERE alias = 'GmOperation' LIMIT 1) AS parent_menu),
menu_name = '金币操作审核',
path = 'gm/gold-operation-review',
menu_type = 2,
icon = 'lucide:badge-check',
create_user = '0',
update_user = '0',
sort = 2,
status = 0,
router = 'gold-operation-review'
WHERE alias = 'GmGoldOperationReview';
DELETE role_menu
FROM sys_role_menu AS role_menu
JOIN sys_menu AS gm_menu
ON gm_menu.id = role_menu.menu_id
LEFT JOIN sys_role AS valid_role
ON valid_role.id = role_menu.role_id
WHERE gm_menu.alias IN ('GmOperation', 'GmGoldOperation', 'GmGoldOperationReview')
AND valid_role.id IS NULL;
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT
valid_source_role.id,
target_menu.id
FROM sys_role_menu AS source_role
JOIN sys_role AS valid_source_role
ON valid_source_role.id = source_role.role_id
JOIN sys_menu AS source_menu
ON source_menu.id = source_role.menu_id
JOIN sys_menu AS target_menu
ON target_menu.alias IN ('GmOperation', 'GmGoldOperation')
LEFT JOIN sys_role_menu AS existing_role_menu
ON existing_role_menu.role_id = valid_source_role.id
AND existing_role_menu.menu_id = target_menu.id
WHERE source_menu.alias IN ('OperateInsideOperationGold', 'OperateUserManage')
AND existing_role_menu.id IS NULL;
DELETE role_menu
FROM sys_role_menu AS role_menu
JOIN sys_menu AS review_menu
ON review_menu.id = role_menu.menu_id
WHERE review_menu.alias = 'GmGoldOperationReview';
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT DISTINCT
yumi_role.id,
target_menu.id
FROM sys_user AS yumi_user
JOIN sys_user_role AS user_role
ON user_role.uid = yumi_user.uid
JOIN sys_role AS yumi_role
ON yumi_role.id = user_role.role_id
JOIN sys_menu AS target_menu
ON target_menu.alias IN ('GmOperation', 'GmGoldOperation', 'GmGoldOperationReview')
LEFT JOIN sys_role_menu AS existing_role_menu
ON existing_role_menu.role_id = yumi_role.id
AND existing_role_menu.menu_id = target_menu.id
WHERE LOWER(yumi_user.login_name) = 'yumiadmin'
AND existing_role_menu.id IS NULL;