This commit is contained in:
sion
2026-04-05 19:43:31 +08:00
parent 5b9a80e3fe
commit 2fbc47117c
94 changed files with 1032 additions and 882 deletions

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import {
ChevronsUpDown,
KeyRound,
LogOut,
UserRoundCog,
} from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { useChangePasswordMutation } from '@/services/api/monisuo-admin.api'
import { useSidebar } from '@/components/ui/sidebar'
import type { User } from './types'
@@ -15,6 +19,40 @@ const { user } = defineProps<
const { logout } = useAuth()
const { isMobile, open } = useSidebar()
const changePasswordMutation = useChangePasswordMutation()
const showPasswordDialog = ref(false)
const passwordForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' })
function openPasswordDialog() {
passwordForm.value = { oldPassword: '', newPassword: '', confirmPassword: '' }
showPasswordDialog.value = true
}
async function handleChangePassword() {
const { oldPassword, newPassword, confirmPassword } = passwordForm.value
if (!oldPassword || !newPassword) {
toast.error('请填写完整')
return
}
if (newPassword.length < 4) {
toast.error('新密码至少4位')
return
}
if (newPassword !== confirmPassword) {
toast.error('两次密码不一致')
return
}
try {
await changePasswordMutation.mutateAsync({ oldPassword, newPassword })
toast.success('密码修改成功,请重新登录')
showPasswordDialog.value = false
logout()
}
catch (e: any) {
toast.error(e.message || e.response?.data?.msg || '修改失败')
}
}
</script>
<template>
@@ -66,6 +104,10 @@ const { isMobile, open } = useSidebar()
<UserRoundCog />
设置
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="openPasswordDialog">
<KeyRound />
修改密码
</UiDropdownMenuItem>
</UiDropdownMenuGroup>
<UiDropdownMenuSeparator />
@@ -77,4 +119,36 @@ const { isMobile, open } = useSidebar()
</UiDropdownMenu>
</UiSidebarMenuItem>
</UiSidebarMenu>
<!-- 修改密码弹窗 -->
<UiDialog v-model:open="showPasswordDialog">
<UiDialogContent class="max-w-md">
<UiDialogHeader>
<UiDialogTitle>修改密码</UiDialogTitle>
</UiDialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<UiLabel>旧密码</UiLabel>
<UiInput v-model="passwordForm.oldPassword" type="password" placeholder="请输入旧密码" />
</div>
<div class="grid gap-2">
<UiLabel>新密码</UiLabel>
<UiInput v-model="passwordForm.newPassword" type="password" placeholder="至少4位" />
</div>
<div class="grid gap-2">
<UiLabel>确认新密码</UiLabel>
<UiInput v-model="passwordForm.confirmPassword" type="password" placeholder="再次输入新密码" @keyup.enter="handleChangePassword" />
</div>
</div>
<UiDialogFooter>
<UiButton variant="outline" @click="showPasswordDialog = false">
取消
</UiButton>
<UiButton :disabled="changePasswordMutation.isPending.value" @click="handleChangePassword">
<UiSpinner v-if="changePasswordMutation.isPending.value" class="mr-2" />
确认修改
</UiButton>
</UiDialogFooter>
</UiDialogContent>
</UiDialog>
</template>

View File

@@ -1,10 +1,13 @@
import { storeToRefs } from 'pinia'
import { useQueryClient } from '@tanstack/vue-query'
import { useAdminLoginMutation } from '@/services/api/monisuo-admin.api'
import { useAuthStore } from '@/stores/auth'
export function useAuth() {
const router = useRouter()
const queryClient = useQueryClient()
const authStore = useAuthStore()
const { isLogin, adminInfo } = storeToRefs(authStore)
@@ -15,6 +18,7 @@ export function useAuth() {
function logout() {
authStore.logout()
queryClient.clear()
router.push({ path: '/auth/sign-in' })
}
@@ -52,7 +56,7 @@ export function useAuth() {
}
catch (e: any) {
console.error('Login error:', e)
error.value = e.response?.data?.msg || '网络错误,请稍后重试'
error.value = e.message || e.response?.data?.msg || '网络错误,请稍后重试'
}
finally {
loading.value = false

View File

@@ -22,6 +22,10 @@ export function useAxios() {
})
axiosInstance.interceptors.response.use((response) => {
const data = response.data as any
if (data && data.code && data.code !== '0000') {
return Promise.reject(new Error(data.msg || '请求失败'))
}
return response
}, (error: AxiosError) => {
if (error.response?.status === 401) {

View File

@@ -1,21 +1,28 @@
import { Coins, DollarSign, Palette, Receipt, Settings, TrendingUp, Users } from 'lucide-vue-next'
import { CircleDollarSign, Coins, DollarSign, Palette, Receipt, Settings, ShieldCheck, TrendingUp, Users } from 'lucide-vue-next'
import type { NavGroup } from '@/components/app-sidebar/types'
import { useAuthStore } from '@/stores/auth'
export function useSidebar() {
const authStore = useAuthStore()
const role = computed(() => authStore.adminInfo?.role ?? 2)
const isSuperAdmin = computed(() => role.value === 1)
const settingsNavItems = [
{ title: '外观设置', url: '/settings/appearance', icon: Palette },
]
const navData = ref<NavGroup[]>([
const allNavItems: NavGroup[] = [
{
title: 'Monisuo 管理',
items: [
{ title: '数据看板', url: '/monisuo/dashboard', icon: DollarSign },
{ title: '用户管理', url: '/monisuo/users', icon: Users },
{ title: '币种管理', url: '/monisuo/coins', icon: Coins },
{ title: '订单审批', url: '/monisuo/orders', icon: Receipt },
{ title: '业务分析', url: '/monisuo/analytics', icon: TrendingUp },
{ title: '数据看板', url: '/monisuo/dashboard', icon: DollarSign, roles: [1] },
{ title: '用户管理', url: '/monisuo/users', icon: Users, roles: [1] },
{ title: '币种管理', url: '/monisuo/coins', icon: Coins, roles: [1] },
{ title: '订单审批', url: '/monisuo/orders', icon: Receipt, roles: [1, 2] },
{ title: '财务审批', url: '/monisuo/finance-orders', icon: CircleDollarSign, roles: [1, 3] },
{ title: '业务分析', url: '/monisuo/analytics', icon: TrendingUp, roles: [1] },
{ title: '管理员管理', url: '/monisuo/admins', icon: ShieldCheck, roles: [1] },
],
},
{
@@ -24,7 +31,19 @@ export function useSidebar() {
{ title: 'Settings', icon: Settings, items: settingsNavItems },
],
},
])
]
const navData = computed<NavGroup[]>(() => {
return allNavItems.map(group => ({
title: group.title,
items: group.items.filter((item) => {
const roles = (item as any).roles as number[] | undefined
if (!roles)
return true
return roles.includes(role.value)
}),
})).filter(group => group.items.length > 0)
})
const otherPages = ref<NavGroup[]>([])
@@ -32,5 +51,6 @@ export function useSidebar() {
navData,
otherPages,
settingsNavItems,
isSuperAdmin,
}
}

View File

@@ -19,6 +19,8 @@ const showEditDialog = ref(false)
const showPriceDialog = ref(false)
const priceInput = ref<number>(0)
const editingCode = ref('')
const editingInitialPrice = ref<number | null>(null)
const editingCurrentPrice = ref<number>(0)
// 表单验证
const formErrors = ref<{ code?: string, name?: string }>({})
@@ -60,12 +62,14 @@ async function saveCoin() {
refetch()
}
catch (e: any) {
toast.error(e.response?.data?.msg || '操作失败')
toast.error(e.message || e.response?.data?.msg || '操作失败')
}
}
function openPriceDialog(coin: Coin) {
editingCode.value = coin.code
editingInitialPrice.value = coin.initialPrice ?? null
editingCurrentPrice.value = coin.price
priceInput.value = coin.price
showPriceDialog.value = true
}
@@ -83,7 +87,7 @@ async function updatePrice() {
refetch()
}
catch (e: any) {
toast.error(e.response?.data?.msg || '操作失败')
toast.error(e.message || e.response?.data?.msg || '操作失败')
}
}
@@ -96,7 +100,7 @@ async function toggleStatus(coin: Coin) {
toast.success(`${action} ${coin.code}`)
}
catch (e: any) {
toast.error(e.response?.data?.msg || `${action}失败`)
toast.error(e.message || e.response?.data?.msg || `${action}失败`)
}
}
@@ -128,7 +132,10 @@ function formatPrice(price: number): string {
<UiTableHead>代码</UiTableHead>
<UiTableHead>名称</UiTableHead>
<UiTableHead class="text-right">
价格
初始价格
</UiTableHead>
<UiTableHead class="text-right">
当前价格
</UiTableHead>
<UiTableHead>价格类型</UiTableHead>
<UiTableHead>状态</UiTableHead>
@@ -139,12 +146,12 @@ function formatPrice(price: number): string {
</UiTableHeader>
<UiTableBody>
<UiTableRow v-if="isLoading">
<UiTableCell :col-span="7" class="text-center py-8">
<UiTableCell :col-span="8" class="text-center py-8">
<UiSpinner class="mx-auto" />
</UiTableCell>
</UiTableRow>
<UiTableRow v-else-if="coins.length === 0">
<UiTableCell :col-span="7" class="text-center py-8 text-muted-foreground">
<UiTableCell :col-span="8" class="text-center py-8 text-muted-foreground">
暂无数据
</UiTableCell>
</UiTableRow>
@@ -154,7 +161,10 @@ function formatPrice(price: number): string {
{{ coin.code }}
</UiTableCell>
<UiTableCell>{{ coin.name }}</UiTableCell>
<UiTableCell class="text-right font-mono">
<UiTableCell class="text-right font-mono text-muted-foreground">
{{ coin.initialPrice != null ? `$${formatPrice(coin.initialPrice)}` : '--' }}
</UiTableCell>
<UiTableCell class="text-right font-mono font-semibold text-green-600 dark:text-green-400">
${{ formatPrice(coin.price) }}
</UiTableCell>
<UiTableCell>
@@ -224,6 +234,9 @@ function formatPrice(price: number): string {
<div class="text-xl font-mono font-bold text-green-600 dark:text-green-400">
${{ formatPrice(coin.price) }}
</div>
<div v-if="coin.initialPrice != null" class="text-xs text-muted-foreground mt-1">
初始价格: ${{ formatPrice(coin.initialPrice) }}
</div>
</div>
<div class="mt-3 flex gap-2">
<UiButton size="sm" variant="outline" class="flex-1" @click="openEditDialog(coin)">
@@ -268,6 +281,7 @@ function formatPrice(price: number): string {
<UiInput
v-model="editingCoin.code"
placeholder="BTC"
:disabled="!!editingCoin.id"
:class="{ 'border-red-500': formErrors.code }"
@input="formErrors.code = undefined"
/>
@@ -289,7 +303,7 @@ function formatPrice(price: number): string {
</div>
<div class="grid gap-2">
<UiLabel>价格类型</UiLabel>
<UiSelect v-model="editingCoin.priceType">
<UiSelect v-model="editingCoin.priceType" :disabled="!!editingCoin.id">
<UiSelectTrigger>
<UiSelectValue />
</UiSelectTrigger>
@@ -303,10 +317,31 @@ function formatPrice(price: number): string {
</UiSelectContent>
</UiSelect>
</div>
<div v-if="editingCoin.priceType === 2" class="grid gap-2">
<!-- 新增时设置初始价格 -->
<div v-if="editingCoin.priceType === 2 && !editingCoin.id" class="grid gap-2">
<UiLabel>初始价格 ($)</UiLabel>
<UiInput v-model.number="editingCoin.price" type="number" step="0.000001" placeholder="0.00" />
</div>
<!-- 编辑时显示初始价格只读和当前价格只读 -->
<template v-if="editingCoin.priceType === 2 && editingCoin.id">
<div v-if="editingCoin.initialPrice != null" class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-muted-foreground">初始价格</span>
<div class="font-mono mt-1">
${{ formatPrice(editingCoin.initialPrice) }}
</div>
</div>
<div>
<span class="text-muted-foreground">当前价格</span>
<div class="font-mono mt-1 text-green-600 dark:text-green-400">
${{ formatPrice(editingCoin.price) }}
</div>
</div>
</div>
<div v-else class="text-sm text-muted-foreground bg-muted/50 rounded-md p-3">
尚未调价初始价格将在首次调价时锁定
</div>
</template>
</div>
<UiDialogFooter>
<UiButton variant="outline" @click="showEditDialog = false">
@@ -327,6 +362,23 @@ function formatPrice(price: number): string {
<UiDialogTitle>调整价格 - {{ editingCode }}</UiDialogTitle>
</UiDialogHeader>
<div class="grid gap-4 py-4">
<div v-if="editingInitialPrice != null" class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-muted-foreground">初始价格</span>
<div class="font-mono mt-1">
${{ formatPrice(editingInitialPrice) }}
</div>
</div>
<div>
<span class="text-muted-foreground">当前价格</span>
<div class="font-mono mt-1 text-green-600 dark:text-green-400">
${{ formatPrice(editingCurrentPrice) }}
</div>
</div>
</div>
<div v-else class="text-sm text-muted-foreground bg-muted/50 rounded-md p-3">
首次调价后当前价格将被锁定为初始价格之后只能调整当前价格
</div>
<div class="grid gap-2">
<UiLabel>新价格 ($)</UiLabel>
<UiInput

View File

@@ -5,9 +5,12 @@ import { toast } from 'vue-sonner'
import type { OrderFund } from '@/services/api/monisuo-admin.api'
import { BasicPage } from '@/components/global-layout'
import { useAuthStore } from '@/stores/auth'
import { useApproveOrderMutation, useGetAllOrdersQuery, useGetPendingOrdersQuery } from '@/services/api/monisuo-admin.api'
const pageNum = ref(1)
// 分离分页状态
const pendingPage = ref(1)
const allPage = ref(1)
const pageSize = ref(10)
const activeTab = ref('pending')
@@ -15,13 +18,16 @@ const activeTab = ref('pending')
const filterType = ref<number | string>('all')
const filterStatus = ref<number | string>('all')
// 是否已加载过全部订单
const allLoaded = ref(false)
const { data: pendingData, isLoading: pendingLoading, refetch: refetchPending } = useGetPendingOrdersQuery({
pageNum: pageNum.value,
pageNum: pendingPage.value,
pageSize: pageSize.value,
})
const { data: allData, isLoading: allLoading, refetch: refetchAll } = useGetAllOrdersQuery({
pageNum: pageNum.value,
pageNum: allPage.value,
pageSize: pageSize.value,
type: filterType.value === 'all' ? undefined : filterType.value as number,
status: filterStatus.value === 'all' ? undefined : filterStatus.value as number,
@@ -29,13 +35,24 @@ const { data: allData, isLoading: allLoading, refetch: refetchAll } = useGetAllO
const approveMutation = useApproveOrderMutation()
const authStore = useAuthStore()
const adminRole = computed(() => authStore.adminInfo?.role ?? 2)
const pendingOrders = computed(() => pendingData.value?.data?.list || [])
const pendingTotal = computed(() => pendingData.value?.data?.total || 0)
const allOrders = computed(() => allData.value?.data?.list || [])
const allTotal = computed(() => allData.value?.data?.total || 0)
const currentTotal = computed(() => activeTab.value === 'pending' ? pendingTotal.value : allTotal.value)
const totalPages = computed(() => Math.ceil(currentTotal.value / pageSize.value))
const pendingTotalPages = computed(() => Math.ceil(pendingTotal.value / pageSize.value))
const allTotalPages = computed(() => Math.ceil(allTotal.value / pageSize.value))
// 切换 tab 时懒加载全部订单
watch(activeTab, (tab) => {
if (tab === 'all' && !allLoaded.value) {
allLoaded.value = true
refetchAll()
}
})
const showApproveDialog = ref(false)
const showDetailDialog = ref(false)
@@ -74,32 +91,37 @@ async function handleApprove() {
toast.success(`订单已${action}`)
showApproveDialog.value = false
refetchPending()
refetchAll()
if (allLoaded.value) refetchAll()
}
catch (e: any) {
toast.error(e.response?.data?.msg || `${action}失败`)
toast.error(e.message || e.response?.data?.msg || `${action}失败`)
}
}
function handlePageChange(page: number) {
pageNum.value = page
refetchPending()
refetchAll()
if (activeTab.value === 'pending') {
pendingPage.value = page
refetchPending()
} else {
allPage.value = page
refetchAll()
}
}
function handlePageSizeChange(size: unknown) {
if (size === null || size === undefined)
return
pageSize.value = Number(size)
pageNum.value = 1
pendingPage.value = 1
allPage.value = 1
refetchPending()
refetchAll()
if (allLoaded.value) refetchAll()
}
function resetFilters() {
filterType.value = 'all'
filterStatus.value = 'all'
pageNum.value = 1
allPage.value = 1
refetchAll()
}
@@ -123,6 +145,7 @@ function getStatusVariant(order: OrderFund): 'default' | 'secondary' | 'destruct
// 提现
if (status === 1) return 'default' // 待审批
if (status === 2) return 'default' // 已完成
if (status === 5) return 'secondary' // 待财务审核
return 'destructive' // 已驳回/已取消
}
}
@@ -157,12 +180,14 @@ function getStatusText(order: OrderFund): string {
// 充值: 仅待确认(status=2)可审批
// 提现: 仅待审批(status=1)可审批
function canApprove(order: OrderFund): boolean {
const role = adminRole.value
if (order.type === 1) {
return order.status === 2 // 充值待确认
}
else {
return order.status === 1 // 提现待审批
}
if (role === 2) return order.status === 1 // 管理员: 提现待审批
if (role === 3) return order.status === 5 // 财务: 待财务审核
if (role === 1) return order.status === 1 || order.status === 5 // 超管
return false
}
// 复制到剪贴板
@@ -437,6 +462,9 @@ function copyToClipboard(text: string) {
<UiTableHead class="hidden xl:table-cell">
时间
</UiTableHead>
<UiTableHead class="hidden lg:table-cell">
审批人
</UiTableHead>
<UiTableHead class="hidden lg:table-cell">
备注
</UiTableHead>
@@ -447,12 +475,12 @@ function copyToClipboard(text: string) {
</UiTableHeader>
<UiTableBody>
<UiTableRow v-if="allLoading">
<UiTableCell :col-span="9" class="text-center py-8">
<UiTableCell :col-span="10" class="text-center py-8">
<UiSpinner class="mx-auto" />
</UiTableCell>
</UiTableRow>
<UiTableRow v-else-if="allOrders.length === 0">
<UiTableCell :col-span="9" class="text-center py-8 text-muted-foreground">
<UiTableCell :col-span="10" class="text-center py-8 text-muted-foreground">
暂无数据
</UiTableCell>
</UiTableRow>
@@ -488,6 +516,10 @@ function copyToClipboard(text: string) {
完成: {{ order.confirmTime }}
</div>
</UiTableCell>
<UiTableCell class="hidden lg:table-cell text-sm text-muted-foreground">
<div v-if="order.approveAdminName">{{ order.approveAdminName }}</div>
<span v-else>-</span>
</UiTableCell>
<UiTableCell class="hidden lg:table-cell text-sm text-muted-foreground max-w-[120px] truncate">
{{ order.rejectReason || order.adminRemark || '-' }}
</UiTableCell>
@@ -547,6 +579,9 @@ function copyToClipboard(text: string) {
<div v-if="order.confirmTime" class="text-xs text-muted-foreground">
完成: {{ order.confirmTime }}
</div>
<div v-if="order.approveAdminName" class="text-xs text-muted-foreground">
审批人: {{ order.approveAdminName }}
</div>
<div v-if="order.rejectReason || order.adminRemark" class="text-sm text-muted-foreground mt-1">
备注: {{ order.rejectReason || order.adminRemark }}
</div>
@@ -566,9 +601,9 @@ function copyToClipboard(text: string) {
</UiTabs>
<!-- 分页 -->
<div v-if="currentTotal > 0" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
<div v-if="(activeTab === 'pending' ? pendingTotal : allTotal) > 0" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
<div class="text-sm text-muted-foreground">
{{ currentTotal }} 条记录
{{ activeTab === 'pending' ? pendingTotal : allTotal }} 条记录
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
@@ -596,20 +631,20 @@ function copyToClipboard(text: string) {
variant="outline"
size="icon"
class="h-8 w-8"
:disabled="pageNum <= 1"
@click="handlePageChange(pageNum - 1)"
:disabled="(activeTab === 'pending' ? pendingPage : allPage) <= 1"
@click="handlePageChange((activeTab === 'pending' ? pendingPage : allPage) - 1)"
>
<Icon icon="lucide:chevron-left" class="size-4" />
</UiButton>
<span class="text-sm min-w-[80px] text-center">
{{ pageNum }} / {{ totalPages }}
{{ activeTab === 'pending' ? pendingPage : allPage }} / {{ activeTab === 'pending' ? pendingTotalPages : allTotalPages }}
</span>
<UiButton
variant="outline"
size="icon"
class="h-8 w-8"
:disabled="pageNum >= totalPages"
@click="handlePageChange(pageNum + 1)"
:disabled="(activeTab === 'pending' ? pendingPage : allPage) >= (activeTab === 'pending' ? pendingTotalPages : allTotalPages)"
@click="handlePageChange((activeTab === 'pending' ? pendingPage : allPage) + 1)"
>
<Icon icon="lucide:chevron-right" class="size-4" />
</UiButton>
@@ -665,6 +700,24 @@ function copyToClipboard(text: string) {
</UiBadge>
</div>
<!-- 手续费信息提现 -->
<template v-if="currentOrder.type === 2 && currentOrder.fee">
<div class="text-muted-foreground">
手续费(10%)
</div>
<div class="col-span-2 font-mono">
-${{ currentOrder.fee }}
</div>
</template>
<template v-if="currentOrder.type === 2 && currentOrder.receivableAmount">
<div class="text-muted-foreground">
应收款项
</div>
<div class="col-span-2 font-mono font-bold text-green-600">
${{ currentOrder.receivableAmount }}
</div>
</template>
<!-- 充值/提现地址 -->
<div class="text-muted-foreground">
{{ currentOrder.type === 1 ? '充值地址' : '提现地址' }}
@@ -704,6 +757,19 @@ function copyToClipboard(text: string) {
</div>
</template>
<!-- 财务审批人 -->
<template v-if="currentOrder.financeAdminName">
<div class="text-muted-foreground">
财务审批人
</div>
<div class="col-span-2">
{{ currentOrder.financeAdminName }}
<span v-if="currentOrder.financeApproveTime" class="text-xs text-muted-foreground ml-2">
{{ currentOrder.financeApproveTime }}
</span>
</div>
</template>
<!-- 管理员确认时间 -->
<template v-if="currentOrder.confirmTime">
<div class="text-muted-foreground">
@@ -714,6 +780,19 @@ function copyToClipboard(text: string) {
</div>
</template>
<!-- 审批人信息 -->
<template v-if="currentOrder.approveAdminName">
<div class="text-muted-foreground">
审批人
</div>
<div class="col-span-2">
{{ currentOrder.approveAdminName }}
<span v-if="currentOrder.approveTime" class="text-xs text-muted-foreground ml-2">
{{ currentOrder.approveTime }}
</span>
</div>
</template>
<template v-if="currentOrder.rejectReason">
<div class="text-muted-foreground text-red-500">
驳回原因

View File

@@ -44,7 +44,7 @@ async function toggleStatus(user: User) {
toast.success(`${action}用户 ${user.username}`)
}
catch (e: any) {
toast.error(e.response?.data?.msg || `${action}失败`)
toast.error(e.message || e.response?.data?.msg || `${action}失败`)
}
}

View File

@@ -76,7 +76,7 @@ async function saveWallet() {
refetch()
}
catch (e: any) {
toast.error(e.response?.data?.msg || '操作失败')
toast.error(e.message || e.response?.data?.msg || '操作失败')
}
}
@@ -89,7 +89,7 @@ async function setDefault(wallet: ColdWallet) {
toast.success(`已将 ${wallet.name} 设为默认`)
}
catch (e: any) {
toast.error(e.response?.data?.msg || '设置失败')
toast.error(e.message || e.response?.data?.msg || '设置失败')
}
}
@@ -99,7 +99,7 @@ async function toggleStatus(wallet: ColdWallet) {
toast.success(wallet.status === 1 ? '已禁用' : '已启用')
}
catch (e: any) {
toast.error(e.response?.data?.msg || '操作失败')
toast.error(e.message || e.response?.data?.msg || '操作失败')
}
}
@@ -112,7 +112,7 @@ async function deleteWallet(wallet: ColdWallet) {
toast.success('钱包已删除')
}
catch (e: any) {
toast.error(e.response?.data?.msg || '删除失败')
toast.error(e.message || e.response?.data?.msg || '删除失败')
}
}
</script>

View File

@@ -1,37 +1,72 @@
import type { Router } from 'vue-router'
import { storeToRefs } from 'pinia'
import pinia from '@/plugins/pinia/setup'
import { useAuthStore } from '@/stores/auth'
// 不需要认证的白名单路由
const WHITE_LIST = ['/auth/sign-in', '/auth/sign-up', '/auth/forgot-password']
export function authGuard(router: Router) {
router.beforeEach((to, _from) => {
// 仅超级管理员可访问的路由前缀
const SUPER_ADMIN_ONLY = ['/monisuo/dashboard', '/monisuo/users', '/monisuo/coins', '/monisuo/analytics', '/monisuo/admins']
export function setupAuthGuard(router: Router) {
router.beforeEach((to) => {
const authStore = useAuthStore(pinia)
const { isLogin } = storeToRefs(authStore)
const { isLogin, adminInfo } = storeToRefs(authStore)
// 检查是否在白名单中
const isInWhiteList = WHITE_LIST.some(path => to.path === path || to.path.startsWith(path + '/'))
console.log('[AuthGuard]', to.path, 'isInWhiteList:', isInWhiteList, 'isLogin:', unref(isLogin))
// 如果已登录且访问登录页,重定向到首页
if (unref(isLogin) && to.path === '/auth/sign-in') {
const role = unref(adminInfo)?.role
if (role === 2) {
return { path: '/monisuo/orders' }
}
if (role === 3) {
return { path: '/monisuo/finance-orders' }
}
return { name: '/monisuo/dashboard' }
}
// 如果未登录且不在白名单中,重定向到登录页
if (!unref(isLogin) && !isInWhiteList) {
console.log('[AuthGuard] Redirecting to login')
return {
name: '/auth/sign-in',
query: { redirect: to.fullPath },
}
}
// 已登录时检查角色权限
if (unref(isLogin) && !isInWhiteList) {
const role = unref(adminInfo)?.role
const isSuperAdmin = role === 1
if (!isSuperAdmin) {
const needsSuperAdmin = SUPER_ADMIN_ONLY.some(prefix => to.path.startsWith(prefix))
if (needsSuperAdmin) {
// 非超管尝试访问超管页面,根据角色重定向
if (role === 3) {
return { path: '/monisuo/finance-orders' }
}
return { path: '/monisuo/orders' }
}
// 财务角色(role=3)只能访问财务审批页面和设置
if (role === 3) {
const allowed = to.path.startsWith('/monisuo/finance-orders') || to.path.startsWith('/settings')
if (!allowed) {
return { path: '/monisuo/finance-orders' }
}
}
// 普通管理员(role=2)不能访问财务审批页面
if (role === 2 && to.path.startsWith('/monisuo/finance-orders')) {
return { path: '/monisuo/orders' }
}
}
}
return true
})
}

View File

@@ -2,11 +2,10 @@ import type { Router } from 'vue-router'
import nprogress from 'nprogress'
import { authGuard } from './auth-guard'
import { setupAuthGuard } from './auth-guard'
/**
* global router guard
* now only used for progress bar
*/
function setupCommonGuard(router: Router) {
router.beforeEach(() => {
@@ -21,5 +20,5 @@ function setupCommonGuard(router: Router) {
export function createRouterGuard(router: Router) {
setupCommonGuard(router)
authGuard(router)
setupAuthGuard(router)
}

View File

@@ -45,6 +45,7 @@ export interface Coin {
code: string
name: string
price: number
initialPrice?: number
priceType: number // 1: 实时 2: 手动
status: number
createTime: string
@@ -57,7 +58,9 @@ export interface OrderFund {
username: string
type: number // 1: 充值 2: 提现
amount: number
status: number // 充值: 1待付款 2待确认 3已完成 4已驳回 5已取消; 提现: 1待审批 2已完成 3已驳回 4已取消
fee?: number // 手续费
receivableAmount?: number // 应收款项
status: number // 充值: 1待付款 2待确认 3已完成 4已驳回 5已取消; 提现: 1待审批 2已完成 3已驳回 4已取消 5待财务审核
walletId?: number
walletAddress?: string
withdrawContact?: string
@@ -66,6 +69,12 @@ export interface OrderFund {
createTime: string
rejectReason?: string
adminRemark?: string
approveAdminId?: number
approveAdminName?: string
approveTime?: string
financeAdminId?: number
financeAdminName?: string
financeApproveTime?: string
}
export interface ColdWallet {
@@ -351,7 +360,91 @@ export function useToggleWalletStatusMutation() {
})
}
// ========== 分析相关 API ==========
// ========== 管理员管理 API ==========
export interface AdminRecord {
id: number
username: string
nickname: string
role: number
permissions?: string
isSystem?: number
status?: number
createTime?: string
}
export function useGetAdminListQuery() {
const { axiosInstance } = useAxios()
return useQuery<ApiResult<AdminRecord[]>, AxiosError>({
queryKey: ['useGetAdminListQuery'],
queryFn: async () => {
const response = await axiosInstance.get('/admin/admin/list')
return response.data
},
})
}
export function useCreateAdminMutation() {
const { axiosInstance } = useAxios()
const queryClient = useQueryClient()
return useMutation<ApiResult<{ id: number, username: string, role: number }>, AxiosError, { username: string, password: string, nickname?: string, role?: number }>({
mutationKey: ['useCreateAdminMutation'],
mutationFn: async (params) => {
const response = await axiosInstance.post('/admin/admin/create', params)
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['useGetAdminListQuery'] })
},
})
}
export function useToggleAdminStatusMutation() {
const { axiosInstance } = useAxios()
const queryClient = useQueryClient()
return useMutation<ApiResult<void>, AxiosError, { id: number, status: number }>({
mutationKey: ['useToggleAdminStatusMutation'],
mutationFn: async (params) => {
const response = await axiosInstance.post('/admin/admin/status', params)
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['useGetAdminListQuery'] })
},
})
}
export function usePromoteUserToAdminMutation() {
const { axiosInstance } = useAxios()
const queryClient = useQueryClient()
return useMutation<ApiResult<{ id: number, username: string, role: number }>, AxiosError, { username: string, password: string, nickname?: string }>({
mutationKey: ['usePromoteUserToAdminMutation'],
mutationFn: async (params) => {
const response = await axiosInstance.post('/admin/user/promote-admin', params)
return response.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['useGetAdminListQuery'] })
queryClient.invalidateQueries({ queryKey: ['useGetUserListQuery'] })
},
})
}
export function useChangePasswordMutation() {
const { axiosInstance } = useAxios()
return useMutation<ApiResult<void>, AxiosError, { oldPassword: string, newPassword: string }>({
mutationKey: ['useChangePasswordMutation'],
mutationFn: async (params) => {
const response = await axiosInstance.post('/admin/change-password', params)
return response.data
},
})
}
// 盈利分析
export function useGetProfitAnalysisQuery(range: string = 'month') {

View File

@@ -61,6 +61,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/monisuo/admins': RouteRecordInfo<
'/monisuo/admins',
'/monisuo/admins',
Record<never, never>,
Record<never, never>,
| never
>,
'/monisuo/analytics': RouteRecordInfo<
'/monisuo/analytics',
'/monisuo/analytics',
@@ -82,6 +89,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/monisuo/finance-orders': RouteRecordInfo<
'/monisuo/finance-orders',
'/monisuo/finance-orders',
Record<never, never>,
Record<never, never>,
| never
>,
'/monisuo/orders': RouteRecordInfo<
'/monisuo/orders',
'/monisuo/orders',
@@ -168,6 +182,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/monisuo/admins.vue': {
routes:
| '/monisuo/admins'
views:
| never
}
'src/pages/monisuo/analytics.vue': {
routes:
| '/monisuo/analytics'
@@ -186,6 +206,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/monisuo/finance-orders.vue': {
routes:
| '/monisuo/finance-orders'
views:
| never
}
'src/pages/monisuo/orders.vue': {
routes:
| '/monisuo/orders'