经理中心相关

This commit is contained in:
zhx 2026-05-12 14:10:45 +08:00
parent 200e1c6fad
commit fc8d5dc12a
4 changed files with 397 additions and 1 deletions

View File

@ -1,6 +1,24 @@
import { requestClient } from '#/api/request';
import type { LegacyPageResult } from '#/api/legacy/system';
const BINANCE_RECHARGE_BASE = '/go/payment/binance';
export async function getBinanceRechargeConfig(sysOrigin: string) {
return requestClient.get<Record<string, any>>(
`${BINANCE_RECHARGE_BASE}/config`,
{
params: { sysOrigin },
},
);
}
export async function saveBinanceRechargeConfig(data: Record<string, any>) {
return requestClient.post<Record<string, any>>(
`${BINANCE_RECHARGE_BASE}/config/save`,
data,
);
}
export async function listPayOpenCountry() {
return requestClient.get<Array<Record<string, any>>>(
'/sys-pay-open-country/list',

View File

@ -25,6 +25,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('#/views/operate/pay-country.vue'),
meta: { title: '开通支付国家' },
},
{
name: 'BinanceRechargeConfig',
path: 'binance/config',
component: () => import('#/views/operate/binance-recharge-config.vue'),
meta: { title: '币安配置' },
},
{
name: 'RegionRelation',
path: 'region/relation',

View File

@ -0,0 +1,368 @@
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import {
getBinanceRechargeConfig,
saveBinanceRechargeConfig,
} from '#/api/legacy/pay';
import { getAllowedSysOrigins } from '#/views/system/shared';
import {
Button,
Card,
Form,
FormItem,
Input,
Select,
Space,
Switch,
Table,
Tag,
message,
} from 'antdv-next';
defineOptions({ name: 'OperateBinanceRechargeConfig' });
let localRowId = 0;
function createRateRow(
minAmount = '0',
maxAmount = '0',
goldPerUsd = '80000',
) {
localRowId += 1;
return {
goldPerUsd,
maxAmount,
minAmount,
rowKey: `binance-rate-${localRowId}`,
};
}
const accessStore = useAccessStore();
const sysOriginOptions = computed(() => {
const options = getAllowedSysOrigins(accessStore.accessCodes || []);
return options.length > 0 ? options : getAllowedSysOrigins([]);
});
const selectedSysOrigin = ref('');
const loading = ref(false);
const saving = ref(false);
const form = reactive<Record<string, any>>({
apiKey: '',
apiSecret: '',
apiSecretConfigured: false,
enabled: true,
rates: [createRateRow()],
sysOrigin: '',
});
const rateColumns = [
{ dataIndex: 'minAmount', key: 'minAmount', title: '最小金额(含)', width: 180 },
{ dataIndex: 'maxAmount', key: 'maxAmount', title: '最大金额(不含)', width: 180 },
{ dataIndex: 'goldPerUsd', key: 'goldPerUsd', title: '1 USDT 对应金币', width: 200 },
{ dataIndex: 'actions', key: 'actions', title: '操作', width: 100 },
];
const statusMeta = computed(() => {
if (!form.apiKey && !form.apiSecretConfigured) {
return { color: 'default', text: '未配置' };
}
if (form.enabled) {
return { color: 'success', text: '已启用' };
}
return { color: 'default', text: '已关闭' };
});
function normalizeDecimalText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeConfig(result: null | Record<string, any> | undefined) {
const value = result || {};
form.sysOrigin = String(value.sysOrigin || selectedSysOrigin.value || '');
form.apiKey = String(value.apiKey || '');
form.apiSecret = '';
form.apiSecretConfigured = Boolean(value.apiSecretConfigured);
form.enabled = value.enabled !== false;
const rates = Array.isArray(value.rates) && value.rates.length > 0
? value.rates
: [createRateRow('0', '100', '80000'), createRateRow('100', '0', '82000')];
form.rates = rates.map((item: Record<string, any>) => ({
goldPerUsd: String(item.goldPerUsd ?? '80000'),
maxAmount: String(item.maxAmount ?? '0'),
minAmount: String(item.minAmount ?? '0'),
rowKey: item.rowKey || createRateRow().rowKey,
}));
}
async function loadConfig() {
if (!selectedSysOrigin.value) {
return;
}
loading.value = true;
try {
const result = await getBinanceRechargeConfig(selectedSysOrigin.value);
normalizeConfig(result);
} finally {
loading.value = false;
}
}
function addRateRow() {
const rows = form.rates as Array<Record<string, any>>;
const last = rows.at(-1);
const nextMin = Number(last?.maxAmount || last?.minAmount || 0);
rows.push(createRateRow(String(Number.isFinite(nextMin) ? nextMin : 0), '0', '80000'));
}
function removeRateRow(index: number) {
(form.rates as Array<Record<string, any>>).splice(index, 1);
}
function validateDecimal(value: unknown) {
const text = normalizeDecimalText(value);
if (!text) {
return false;
}
const numeric = Number(text);
return Number.isFinite(numeric);
}
function validateBeforeSave() {
if (!selectedSysOrigin.value) {
message.warning('请选择系统');
return false;
}
if (!String(form.apiKey || '').trim()) {
message.warning('请输入 API Key');
return false;
}
if (!form.apiSecretConfigured && !String(form.apiSecret || '').trim()) {
message.warning('请输入 API 秘钥');
return false;
}
const rows = form.rates as Array<Record<string, any>>;
if (rows.length === 0) {
message.warning('请至少配置一个发金币区间');
return false;
}
const normalized = rows.map((item) => ({
goldPerUsd: normalizeDecimalText(item.goldPerUsd),
maxAmount: normalizeDecimalText(item.maxAmount || '0'),
minAmount: normalizeDecimalText(item.minAmount),
}));
for (const item of normalized) {
if (
!validateDecimal(item.minAmount) ||
!validateDecimal(item.maxAmount) ||
!validateDecimal(item.goldPerUsd)
) {
message.warning('金额和金币比例必须是数字');
return false;
}
const minAmount = Number(item.minAmount);
const maxAmount = Number(item.maxAmount);
const goldPerUsd = Number(item.goldPerUsd);
if (minAmount < 0 || maxAmount < 0) {
message.warning('金额区间不能小于 0');
return false;
}
if (maxAmount > 0 && maxAmount <= minAmount) {
message.warning('最大金额必须大于最小金额,或填 0 表示不限制');
return false;
}
if (goldPerUsd <= 0) {
message.warning('金币比例必须大于 0');
return false;
}
}
const sorted = [...normalized].sort(
(left, right) => Number(left.minAmount) - Number(right.minAmount),
);
for (let index = 0; index < sorted.length; index += 1) {
const current = sorted[index];
const next = sorted[index + 1];
const currentMax = Number(current?.maxAmount || 0);
if (currentMax === 0 && index !== sorted.length - 1) {
message.warning('不限制最大金额的区间必须放在最后');
return false;
}
if (next && currentMax > Number(next.minAmount)) {
message.warning('发金币区间不能重叠');
return false;
}
}
return true;
}
async function submitForm() {
if (!validateBeforeSave()) {
return;
}
saving.value = true;
try {
await saveBinanceRechargeConfig({
apiKey: String(form.apiKey || '').trim(),
apiSecret: String(form.apiSecret || '').trim(),
enabled: Boolean(form.enabled),
rates: (form.rates as Array<Record<string, any>>).map((item) => ({
goldPerUsd: normalizeDecimalText(item.goldPerUsd),
maxAmount: normalizeDecimalText(item.maxAmount || '0'),
minAmount: normalizeDecimalText(item.minAmount),
})),
sysOrigin: selectedSysOrigin.value,
});
message.success('保存成功');
await loadConfig();
} finally {
saving.value = false;
}
}
watch(
sysOriginOptions,
(options) => {
if (!selectedSysOrigin.value) {
selectedSysOrigin.value = String(options[0]?.value || '');
}
},
{ immediate: true },
);
watch(
selectedSysOrigin,
(value) => {
if (value) {
void loadConfig();
}
},
{ immediate: true },
);
</script>
<template>
<Page auto-content-height>
<Card :bordered="false" title="币安配置">
<div class="toolbar">
<Select
v-model:value="selectedSysOrigin"
:options="sysOriginOptions"
option-filter-prop="label"
option-label-prop="label"
placeholder="请选择系统"
show-search
style="width: 180px"
/>
<Tag :color="statusMeta.color">{{ statusMeta.text }}</Tag>
<Button :loading="loading" @click="loadConfig">刷新</Button>
</div>
<Form layout="vertical">
<div class="form-grid">
<FormItem label="启用自动充值验证">
<Switch v-model:checked="form.enabled" />
</FormItem>
<FormItem label="API Key">
<Input
v-model:value="form.apiKey"
allow-clear
placeholder="请输入 Binance API Key"
/>
</FormItem>
<FormItem label="API 秘钥">
<Input
v-model:value="form.apiSecret"
allow-clear
:placeholder="form.apiSecretConfigured ? '留空则不修改已保存秘钥' : '请输入 Binance API 秘钥'"
type="password"
/>
</FormItem>
</div>
</Form>
<div class="section-head">
<div class="section-title">发金币区间</div>
<Space>
<Button @click="addRateRow">新增区间</Button>
<Button :loading="saving" type="primary" @click="submitForm">保存配置</Button>
</Space>
</div>
<Table
:columns="rateColumns"
:data-source="form.rates"
:loading="loading"
:pagination="false"
row-key="rowKey"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, index, record }">
<template v-if="column.key === 'minAmount'">
<Input
v-model:value="record.minAmount"
placeholder="0"
style="width: 140px"
/>
</template>
<template v-else-if="column.key === 'maxAmount'">
<Input
v-model:value="record.maxAmount"
placeholder="0 表示不限制"
style="width: 140px"
/>
</template>
<template v-else-if="column.key === 'goldPerUsd'">
<Input
v-model:value="record.goldPerUsd"
placeholder="80000"
style="width: 160px"
/>
</template>
<template v-else-if="column.key === 'actions'">
<Button danger type="link" @click="removeRateRow(index)">删除</Button>
</template>
</template>
</Table>
</Card>
</Page>
</template>
<style scoped>
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 20px;
}
.form-grid {
display: grid;
grid-template-columns: minmax(160px, 220px) minmax(240px, 1fr) minmax(240px, 1fr);
gap: 16px;
}
.section-head {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin: 8px 0 12px;
}
.section-title {
font-size: 15px;
font-weight: 600;
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -71,6 +71,10 @@ const canEdit = computed(() => hasPermission('team:manager:list:edit'));
const canDelete = computed(() => hasPermission('team:manager:list:del'));
const showOperationCol = computed(() => canEdit.value || canDelete.value);
function getUserShortId(profile?: Record<string, any> | null) {
return profile?.actualAccount || profile?.account || profile?.id || '-';
}
async function loadData(reset = false) {
if (reset) {
query.cursor = 1;
@ -170,7 +174,7 @@ loadData(true);
<div class="user-name-row">
<span>{{ record.userProfile?.userNickname || '-' }}</span>
<span class="user-short-id">
短ID{{ record.userProfile?.actualAccount || '-' }}
短ID{{ getUserShortId(record.userProfile) }}
</span>
</div>
<div class="user-sub">