添加页面

This commit is contained in:
2026-06-15 13:40:49 +08:00
parent 1cdcbc9abe
commit 1810a7f88f
9 changed files with 810 additions and 6 deletions

View File

@ -0,0 +1,32 @@
import { request } from '@/api';
/**
* @description:
*/
export async function getVarietyEnumsListApi(): Promise<any> {
return request.get<any>('/contractVariety/getVarietyEnumsList')
}
/**
* @description:
* @param params - { code: 编码, name: 名称, status: 状态(1/2) }
*/
export async function addVarietyEnumsApi(params: { code: string; name: string; status: number }): Promise<any> {
return request.post<any>('/contractVariety/addVarietyEnums', params)
}
/**
* @description:
* @param params - { id, code: 编码, name: 名称, status: 状态(1/2) }
*/
export async function editVarietyEnumsApi(params: { id: number; code: string; name: string; status: number }): Promise<any> {
return request.post<any>('/contractVariety/editVarietyEnums', params)
}
/**
* @description:
* @param params - { id }
*/
export async function delVarietyEnumsApi(params: { id: number }): Promise<any> {
return request.post<any>('/contractVariety/delVarietyEnums', params)
}

View File

@ -0,0 +1,79 @@
<template>
<span>{{ animatedValue }}</span>
</template>
<script setup lang="ts">
defineOptions({
name: 'AnimatedNumber'
})
import { ref, watch, onMounted, onUnmounted } from 'vue';
interface Props {
value: number;
duration?: number;
decimals?: number;
separator?: string;
}
const props = withDefaults(defineProps<Props>(), {
duration: 1000,
decimals: 2,
separator: ','
});
const animatedValue = ref('0.00');
let animationFrameId: number | null = null;
let startTime: number | null = null;
const startValue = ref(0);
//
const formatNumber = (num: number): string => {
const fixed = num.toFixed(props.decimals);
const parts = fixed.split('.');
parts[0] && (parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, props.separator));
return parts.join('.');
};
//
const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / props.duration, 1);
// 使 easeOutQuart
const easeProgress = 1 - Math.pow(1 - progress, 4);
const currentValue = startValue.value + (props.value - startValue.value) * easeProgress;
animatedValue.value = formatNumber(currentValue);
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
}
};
//
const startAnimation = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
startTime = null;
startValue.value = parseFloat(animatedValue.value.replace(/,/g, '')) || 0;
animationFrameId = requestAnimationFrame(animate);
};
//
watch(() => props.value, () => {
startAnimation();
}, { immediate: false });
onMounted(() => {
startAnimation();
});
onUnmounted(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
});
</script>

View File

@ -0,0 +1,354 @@
<template>
<el-drawer v-model="innerVisible" :title="drawerTitle" direction="rtl" size="80%" header-align="center">
<div class="drawer-content">
<!-- 顶部客户信息卡片 -->
<div class="user-top" v-loading="loading">
<div class="left-card">
<div class="avatar-wrapper">
<el-icon class="avatar-icon">
<UserFilled />
</el-icon>
</div>
<div class="left-text">
<p>{{ userInfo.name }}</p>
<p>ID{{ userInfo.id }}</p>
<p>{{ userInfo.role }}</p>
</div>
</div>
<div class="right-card">
<div class="item">
<div class="item-label">
<el-icon>
<Phone />
</el-icon>
<span>{{ t('agentUser.phone') }}</span>
</div>
<div class="item-value">{{ userInfo.phone || '-' }}</div>
</div>
<div class="item">
<div class="item-label">
<el-icon>
<CreditCard />
</el-icon>
<span>{{ t('agentUser.accountBalance') }}</span>
</div>
<div class="item-value text-highlight">
<AnimatedNumber :value="Number(userInfo.balance) || 0" />
</div>
</div>
<div class="item">
<div class="item-label">
<el-icon>
<Money />
</el-icon>
<span>{{ t('agentUser.depositAmount') }}</span>
</div>
<div class="item-value text-highlight">
<AnimatedNumber :value="Number(userInfo.deposit) || 0" />
</div>
</div>
<div class="item">
<div class="item-label">
<el-icon>
<Message />
</el-icon>
<span>{{ t('agentUser.email') }}</span>
</div>
<div class="item-value">{{ userInfo.email || '-' }}</div>
</div>
<div class="item">
<div class="item-label">
<el-icon>
<Clock />
</el-icon>
<span>{{ t('agentUser.registerTime') }}</span>
</div>
<div class="item-value">{{ userInfo.registerTime || '-' }}</div>
</div>
<div class="item">
<div class="item-label">
<el-icon>
<Wallet />
</el-icon>
<span>{{ t('agentUser.historyTradeAmount') }}</span>
</div>
<div class="item-value">
<AnimatedNumber :value="Number(userInfo.hisMeny) || 0" />
</div>
</div>
</div>
</div>
<!-- 账户表格 -->
<div class="table-container" v-loading="tableLoading">
<CustomerListTable :table-data="tableData" :columns="tableColumnsOptions" row-key="tradeAccount"
:default-sort="{ prop: 'addTime', order: 'descending' }">
</CustomerListTable>
</div>
</div>
</el-drawer>
</template>
<script setup lang="ts">
defineOptions({
name: 'UserDetailDrawer'
})
import { ref, watch, computed } from 'vue'
import { ElIcon, ElDrawer, ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { UserFilled, Phone, Message, Money, Wallet, Clock, CreditCard } from '@element-plus/icons-vue'
import CustomerListTable from '@/components/common/Table.vue'
import { getCustomerDetailApi } from '@/api/modules/agent/customerDetail'
import { useLanguageStore } from '@/stores/modules/language'
import dayjs from 'dayjs'
import AnimatedNumber from './AnimatedNumber.vue'
//
import type {
RoleType,
CustomerUserInfo,
Account,
CustomerDetailResponse,
UserDisplayInfo,
TableDataRow,
TableColumn
} from '@/api/types/agent'
const { t, locale } = useI18n()
const languageStore = useLanguageStore()
// 使 computed
const roleMap = computed<Record<RoleType, string>>(() => ({
1: t('agentUser.role.1'),
2: t('agentUser.role.2'),
3: t('agentUser.role.3'),
4: t('agentUser.role.4'),
5: t('agentUser.role.5'),
}))
// Props
const props = defineProps<{
drawerVisible: boolean
queryId: string | number
drawerTitle: string
}>()
//
const emit = defineEmits<{
'update:drawerVisible': [value: boolean]
}>()
// =============== computed v-model ===============
const innerVisible = computed({
get() {
return props.drawerVisible
},
set(val) {
emit('update:drawerVisible', val)
}
})
//
const loading = ref(false)
const tableLoading = ref(false)
//
const userInfo = ref<UserDisplayInfo>({
name: '',
id: '',
role: '',
phone: '',
email: '',
deposit: 0,
balance: 0,
registerTime: '',
hisMeny: 0,
})
//
const tableData = ref<TableDataRow[]>([])
//
const tableColumnsOptions = computed<TableColumn[]>(() => [
{ label: t('agentUser.tradeAccount'), prop: 'tradeAccount' },
{ label: t('agentUser.accountName'), prop: 'accountName' },
{ label: t('agentUser.balance'), prop: 'balance', sortable: true },
{ label: t('agentUser.tradeLeverage'), prop: 'leverage' },
{ label: t('agentUser.historyTradeVolume'), prop: 'tradeVolume' },
{ label: t('agentUser.addTime'), prop: 'addTime', width: 180 }
])
// ===================== =====================
const loadDetailData = async (id: string | number) => {
loading.value = true
tableLoading.value = true
try {
const res: CustomerDetailResponse = await getCustomerDetailApi({ user_id: id });
const { accounts, user } = res;
userInfo.value = {
name: user.email || user.mobile || '-',
id: user.id,
role: roleMap.value[user.role_id] || '-',
phone: user.mobile || '-',
email: user.email || '-',
balance: Number(user.capital_amount) || 0,
deposit: Number(user.total_recharge_amount) || 0,
registerTime: dayjs(user.created_at).format('YYYY-MM-DD HH:mm:ss'),
hisMeny: Number(user.history_transfer_amount) || 0,
}
//
tableData.value = accounts.map((item: Account): TableDataRow => ({
tradeAccount: item.account_id,
accountName: item.account_name,
balance: Number(item.wallets_trade.amount) || 0,
leverage: Number(item.wallets_trade.lever) || 0,
tradeVolume: Number(item.collect_data) || 0,
addTime: dayjs(item.created_at).format('YYYY-MM-DD HH:mm:ss'),
}))
} catch (err) {
console.error('加载详情失败:', err);
ElMessage.error(t('agentUser.loadDetailFailed'))
} finally {
loading.value = false
tableLoading.value = false
}
}
// ===================== ID =====================
watch(
() => props.queryId,
async (id) => {
if (!id) return
await loadDetailData(id)
},
{ immediate: true }
)
// i18n locale
watch(
() => languageStore.lang,
(newLang) => {
locale.value = newLang
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.drawer-content {
padding: 0px 32px 28px 32px;
}
.user-top {
display: flex;
gap: 24px;
margin-bottom: 32px;
flex-wrap: wrap;
}
.left-card {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 32px;
background: #fafbfc;
border-radius: 16px;
border: 1px solid #f0f2f5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
min-width: 300px;
.avatar-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
background: #f2f3f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-icon {
font-size: 48px;
color: #909399;
}
}
}
.left-text {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 15px;
font-weight: 500;
color: #1d2129;
}
.left-text p {
margin: 0;
line-height: 1.4;
}
.right-card {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 28px 36px;
padding: 24px 32px;
background: #fafbfc;
border-radius: 16px;
border: 1px solid #f0f2f5;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
align-items: flex-start;
}
.item {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}
.item-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #86909c;
}
.item-label :deep(.el-icon) {
font-size: 16px;
color: #86909c;
}
.item-value {
font-size: 14px;
font-weight: 500;
color: #1d2129;
}
.text-highlight {
color: #1677ff;
font-weight: 600;
}
.table-container {
width: 100%;
height: auto;
margin-top: 10px;
}
</style>

View File

@ -131,8 +131,8 @@ import CustomerListPagination from '@/components/common/Pagination.vue'
import CustomerListDialog from '@/components/common/Dialog.vue'
import CustomerListForm from '@/components/common/Form.vue'
// 👇 default.vue
import CustomerDetailDrawer from './default.vue'
// 👇
import CustomerDetailDrawer from '@/components/common/UserDetailDrawer.vue'
//
import { search, tableColumns, marginRatioTableColumns, commissionRatioFormItem } from './options'

View File

@ -19,10 +19,10 @@
</el-button>
</template>
<template #action="{ row }">
<!-- <el-button link type="primary" size="small" @click="handleDetail(row)" v-if="row.userId">
查看详情
</el-button> -->
<!-- <el-divider direction="vertical" /> -->
<el-button link type="primary" size="small" @click="openDetailDrawer(row)" v-if="row.userId">
查看详情
</el-button>
<el-divider direction="vertical" v-if="row.userId" />
<el-button link type="primary" size="small" @click="handleEditCustomer(row)">
编辑
</el-button>
@ -101,6 +101,10 @@
</el-form-item>
</el-form>
</CustomerListDialog>
<!-- ===================== 代理详情抽屉 ===================== -->
<UserDetailDrawer v-model:drawer-visible="detailDrawerVisible" :query-id="detailQueryId"
:drawer-title="detailDrawerTitle" />
</div>
</template>
@ -116,6 +120,7 @@ import { usePageLoading, useButtonLoading } from '@/composables/useLoading'
import CustomerListPagination from '@/components/common/Pagination.vue'
import CustomerListDialog from '@/components/common/Dialog.vue'
import CustomerListForm from '@/components/common/Form.vue'
import UserDetailDrawer from '@/components/common/UserDetailDrawer.vue'
//
import { search, tableColumns, marginRatioTableColumns, commissionRatioFormItem } from './options'
@ -279,6 +284,19 @@ const adjustAmountRules = {
],
}
/**
* @description 详情抽屉相关
*/
const detailDrawerVisible = ref(false)
const detailQueryId = ref<string | number>('')
const detailDrawerTitle = ref('代理详情')
//
const openDetailDrawer = (row: any) => {
detailQueryId.value = row.userId
detailDrawerVisible.value = true
}
/**
* @description: 角色映射方法
*/

View File

@ -16,6 +16,10 @@
{{ row.status === 1 ? '正常' : '禁用' }}
</template>
<template #action="{ row }">
<el-button type="primary" size="small" @click="openDetailDrawer(row)" v-if="row.id">
查看详情
</el-button>
<el-divider direction="vertical" v-if="row.id" />
<el-button type="primary" size="small" @click="handleChange(row)" v-anth="'user_editUser'">
修改
</el-button>
@ -28,6 +32,11 @@
<ClientPagination v-model="currentPage" v-model:pageSizeValue="pageSize" :total="total"
@pagination-change="handlePaginationChange" />
</div>
<!-- ===================== 客户详情抽屉 ===================== -->
<UserDetailDrawer v-model:drawer-visible="detailDrawerVisible" :query-id="detailQueryId"
:drawer-title="detailDrawerTitle" />
<!-- 客户弹窗 -->
<ClientDialog :visible="dialogVisible" :title="dialogTitle" :type="dialogType" :button-loading="submitLoading"
@confirm="handleSubmit" @cancel="handleCancel" @close="handleClose">
@ -100,6 +109,7 @@ import { usePageLoading, useButtonLoading } from '@/composables/useLoading'
import ClientPagination from '@/components/common/Pagination.vue'
import ClientDialog from '@/components/common/Dialog.vue'
import ClientForm from '@/components/common/Form.vue'
import UserDetailDrawer from '@/components/common/UserDetailDrawer.vue'
//
import {
getClientListApi,
@ -160,6 +170,17 @@ const dialogType = ref('add')
const adjustAmountVisible = ref(false)
const adjustAmountFormRef = ref()
const adjustUserId = ref<number>(0)
//
const detailDrawerVisible = ref(false)
const detailQueryId = ref<string | number>('')
const detailDrawerTitle = ref('客户详情')
//
const openDetailDrawer = (row: any) => {
detailQueryId.value = row.id
detailDrawerVisible.value = true
}
const adjustAmountForm = ref({
username: '',
currency: '',

View File

@ -0,0 +1,254 @@
<template>
<div class="variety-enums table_form-container">
<!-- 搜索 -->
<el-button style="width: 70px;" type="primary" @click="handleAdd()" v-auth="'contractVariety_addVarietyEnums'">
新增
</el-button>
<!-- 表格 -->
<VarietyEnumsTable
:table-data="tableData"
:columns="tableColumnsOptions"
row-key="id"
@row-click="handleRowClick"
:loading="loading">
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
<template #action="{ row }">
<el-button type="primary" link @click.stop="handleEdit(row)" v-auth="'contractVariety_editVarietyEnums'">
编辑
</el-button>
<el-button type="danger" link @click.stop="handleDelete(row)" v-auth="'contractVariety_delVarietyEnums'">
删除
</el-button>
</template>
</VarietyEnumsTable>
</div>
<!-- dialog -->
<VarietyEnumsDialog
:visible="dialogVisible"
:title="dialogTitle"
:button-loading="submitLoading"
@confirm="handleSubmit"
@cancel="handleCancel"
@close="handleClose">
<VarietyEnumsForm
:form-data="formData"
:form-rules="formRules"
:form-items="dialogFormItemOptions"
:options-map="formOptionsMap"
:row-gutter="rowGutter"
:col-span="colSpan"
ref="formRef">
</VarietyEnumsForm>
</VarietyEnumsDialog>
</template>
<script setup lang="ts">
import { usePageLoading, useButtonLoading } from '@/composables/useLoading'
import { ElMessage, ElMessageBox } from 'element-plus'
defineOptions({
name: 'VarietyEnums'
})
//
const { loading, startLoading, stopLoading } = usePageLoading()
const { buttonLoading: submitLoading, startButtonLoading: startSubmitLoading, stopButtonLoading: stopSubmitLoading } = useButtonLoading()
import VarietyEnumsSearch from '@/components/common/Search.vue'
import VarietyEnumsTable from '@/components/common/Table.vue'
import VarietyEnumsDialog from '@/components/common/Dialog.vue'
import VarietyEnumsForm from '@/components/common/Form.vue'
//
import { search, tableColumns, DialogFormItem } from './options'
import { rules } from './rules'
//
import {
getVarietyEnumsListApi,
addVarietyEnumsApi,
editVarietyEnumsApi,
delVarietyEnumsApi,
} from '@/api/modules/trade/varietyEnums'
/**
* @description: 定义搜索数据
*/
const searchForm = ref({})
const searchOptions = ref(search)
/**
* @description: 定义表格数据
*/
const tableData = ref<any[]>([])
const tableColumnsOptions = ref([...tableColumns, { slotName: 'action', label: '操作', align: 'center' as const }])
/**
* @description: 定义弹窗数据
*/
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogType = ref('')
/**
* @description: 定义弹窗表单数据
*/
const formData = ref({
id: 0,
code: '',
name: '',
status: 1,
})
const formRef = ref<any>()
const formRules = ref(rules())
const dialogFormItemOptions = ref(DialogFormItem)
const formOptionsMap = ref({
status: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 2 },
],
})
const rowGutter = ref(20)
const colSpan = ref(24)
//
const selectedRow = ref<any>(null)
/**
* @description: 获取品种枚举列表
*/
const getList = async () => {
startLoading()
try {
const data = await getVarietyEnumsListApi()
tableData.value = data || []
} finally {
stopLoading()
}
}
/**
* @description: 页面加载完成需要请求的数据
*/
onMounted(() => {
getList()
})
/**
* @description: 定义搜索方法
*/
const handleSearch = async () => {
getList()
}
const handleReset = () => {
searchForm.value = {}
handleSearch()
}
//
const handleRowClick = (row: any) => {
if (!row) {
selectedRow.value = null
return
}
selectedRow.value = row
}
/**
* @description: 新增
*/
const handleAdd = () => {
dialogVisible.value = true
dialogTitle.value = '新增品种枚举'
dialogType.value = 'add'
formData.value = {
id: 0,
code: '',
name: '',
status: 1,
}
}
/**
* @description: 编辑
*/
const handleEdit = (row: any) => {
dialogVisible.value = true
dialogTitle.value = '编辑品种枚举'
dialogType.value = 'edit'
formData.value = {
id: row.id,
code: row.code,
name: row.name,
status: row.status,
}
}
/**
* @description: 删除
*/
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该品种枚举吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
await delVarietyEnumsApi({ id: row.id })
ElMessage.success('删除成功')
getList()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
/**
* @description: 定义弹窗方法
*/
const handleSubmit = async () => {
startSubmitLoading()
try {
await formRef.value?.validate()
const params = {
code: formData.value.code,
name: formData.value.name,
status: formData.value.status,
}
if (dialogType.value === 'edit') {
await editVarietyEnumsApi({
id: Number(formData.value.id),
...params,
})
ElMessage.success('编辑成功')
} else {
await addVarietyEnumsApi(params)
ElMessage.success('新增成功')
}
dialogVisible.value = false
getList()
} finally {
stopSubmitLoading()
}
}
const handleCancel = () => {
dialogVisible.value = false
}
const handleClose = () => {
dialogVisible.value = false
}
</script>
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,34 @@
/**
* @description:
*/
export const search = []
/**
* @description:
*/
export const tableColumns = [
// ID
{ prop: 'id', label: 'ID', align: 'center' as const },
// 编码
{ prop: 'code', label: '编码', align: 'center' as const },
// 名称
{ prop: 'name', label: '名称', align: 'center' as const },
// 状态
{ slotName: 'status', label: '状态', align: 'center' as const },
// 创建时间
{ prop: 'created_at', label: '创建时间', align: 'center' as const },
// 更新时间
{ prop: 'updated_at', label: '更新时间', align: 'center' as const },
]
/**
* @description: dialog表单配置
*/
export const DialogFormItem = [
// 编码
{ prop: 'code', label: '编码', type: 'input' as const, placeholder: '请输入编码' },
// 名称
{ prop: 'name', label: '名称', type: 'input' as const, placeholder: '请输入名称' },
// 状态
{ prop: 'status', label: '状态', type: 'select' as const, placeholder: '请选择状态' },
]

View File

@ -0,0 +1,12 @@
import type { FormRules } from 'element-plus'
export const rules = (): FormRules => {
return {
// 编码
code: [{ required: true, message: '请输入编码', trigger: ['blur', 'change'] }],
// 名称
name: [{ required: true, message: '请输入名称', trigger: ['blur', 'change'] }],
// 状态
status: [{ required: true, message: '请选择状态', trigger: ['blur', 'change'] }],
}
}