优化
This commit is contained in:
18
monisuo-admin/src/pages/[...path].vue
Normal file
18
monisuo-admin/src/pages/[...path].vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import Error from '@/components/custom-error.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center h-screen">
|
||||
<Error
|
||||
:code="404"
|
||||
subtitle="Page Not Found"
|
||||
error="The page you are looking for might have been removed, had its name changed, or is temporarily unavailable."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: false
|
||||
</route>
|
||||
273
monisuo-admin/src/pages/dashboard/components/overview-chart.vue
Normal file
273
monisuo-admin/src/pages/dashboard/components/overview-chart.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
|
||||
|
||||
import type { ChartConfig } from '@/components/ui/chart'
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
|
||||
ChartContainer,
|
||||
ChartCrosshair,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
componentToString,
|
||||
} from '@/components/ui/chart'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
const chartData = [
|
||||
{ date: new Date('2024-04-01'), desktop: 222, mobile: 150 },
|
||||
{ date: new Date('2024-04-02'), desktop: 97, mobile: 180 },
|
||||
{ date: new Date('2024-04-03'), desktop: 167, mobile: 120 },
|
||||
{ date: new Date('2024-04-04'), desktop: 242, mobile: 260 },
|
||||
{ date: new Date('2024-04-05'), desktop: 373, mobile: 290 },
|
||||
{ date: new Date('2024-04-06'), desktop: 301, mobile: 340 },
|
||||
{ date: new Date('2024-04-07'), desktop: 245, mobile: 180 },
|
||||
{ date: new Date('2024-04-08'), desktop: 409, mobile: 320 },
|
||||
{ date: new Date('2024-04-09'), desktop: 59, mobile: 110 },
|
||||
{ date: new Date('2024-04-10'), desktop: 261, mobile: 190 },
|
||||
{ date: new Date('2024-04-11'), desktop: 327, mobile: 350 },
|
||||
{ date: new Date('2024-04-12'), desktop: 292, mobile: 210 },
|
||||
{ date: new Date('2024-04-13'), desktop: 342, mobile: 380 },
|
||||
{ date: new Date('2024-04-14'), desktop: 137, mobile: 220 },
|
||||
{ date: new Date('2024-04-15'), desktop: 120, mobile: 170 },
|
||||
{ date: new Date('2024-04-16'), desktop: 138, mobile: 190 },
|
||||
{ date: new Date('2024-04-17'), desktop: 446, mobile: 360 },
|
||||
{ date: new Date('2024-04-18'), desktop: 364, mobile: 410 },
|
||||
{ date: new Date('2024-04-19'), desktop: 243, mobile: 180 },
|
||||
{ date: new Date('2024-04-20'), desktop: 89, mobile: 150 },
|
||||
{ date: new Date('2024-04-21'), desktop: 137, mobile: 200 },
|
||||
{ date: new Date('2024-04-22'), desktop: 224, mobile: 170 },
|
||||
{ date: new Date('2024-04-23'), desktop: 138, mobile: 230 },
|
||||
{ date: new Date('2024-04-24'), desktop: 387, mobile: 290 },
|
||||
{ date: new Date('2024-04-25'), desktop: 215, mobile: 250 },
|
||||
{ date: new Date('2024-04-26'), desktop: 75, mobile: 130 },
|
||||
{ date: new Date('2024-04-27'), desktop: 383, mobile: 420 },
|
||||
{ date: new Date('2024-04-28'), desktop: 122, mobile: 180 },
|
||||
{ date: new Date('2024-04-29'), desktop: 315, mobile: 240 },
|
||||
{ date: new Date('2024-04-30'), desktop: 454, mobile: 380 },
|
||||
{ date: new Date('2024-05-01'), desktop: 165, mobile: 220 },
|
||||
{ date: new Date('2024-05-02'), desktop: 293, mobile: 310 },
|
||||
{ date: new Date('2024-05-03'), desktop: 247, mobile: 190 },
|
||||
{ date: new Date('2024-05-04'), desktop: 385, mobile: 420 },
|
||||
{ date: new Date('2024-05-05'), desktop: 481, mobile: 390 },
|
||||
{ date: new Date('2024-05-06'), desktop: 498, mobile: 520 },
|
||||
{ date: new Date('2024-05-07'), desktop: 388, mobile: 300 },
|
||||
{ date: new Date('2024-05-08'), desktop: 149, mobile: 210 },
|
||||
{ date: new Date('2024-05-09'), desktop: 227, mobile: 180 },
|
||||
{ date: new Date('2024-05-10'), desktop: 293, mobile: 330 },
|
||||
{ date: new Date('2024-05-11'), desktop: 335, mobile: 270 },
|
||||
{ date: new Date('2024-05-12'), desktop: 197, mobile: 240 },
|
||||
{ date: new Date('2024-05-13'), desktop: 197, mobile: 160 },
|
||||
{ date: new Date('2024-05-14'), desktop: 448, mobile: 490 },
|
||||
{ date: new Date('2024-05-15'), desktop: 473, mobile: 380 },
|
||||
{ date: new Date('2024-05-16'), desktop: 338, mobile: 400 },
|
||||
{ date: new Date('2024-05-17'), desktop: 499, mobile: 420 },
|
||||
{ date: new Date('2024-05-18'), desktop: 315, mobile: 350 },
|
||||
{ date: new Date('2024-05-19'), desktop: 235, mobile: 180 },
|
||||
{ date: new Date('2024-05-20'), desktop: 177, mobile: 230 },
|
||||
{ date: new Date('2024-05-21'), desktop: 82, mobile: 140 },
|
||||
{ date: new Date('2024-05-22'), desktop: 81, mobile: 120 },
|
||||
{ date: new Date('2024-05-23'), desktop: 252, mobile: 290 },
|
||||
{ date: new Date('2024-05-24'), desktop: 294, mobile: 220 },
|
||||
{ date: new Date('2024-05-25'), desktop: 201, mobile: 250 },
|
||||
{ date: new Date('2024-05-26'), desktop: 213, mobile: 170 },
|
||||
{ date: new Date('2024-05-27'), desktop: 420, mobile: 460 },
|
||||
{ date: new Date('2024-05-28'), desktop: 233, mobile: 190 },
|
||||
{ date: new Date('2024-05-29'), desktop: 78, mobile: 130 },
|
||||
{ date: new Date('2024-05-30'), desktop: 340, mobile: 280 },
|
||||
{ date: new Date('2024-05-31'), desktop: 178, mobile: 230 },
|
||||
{ date: new Date('2024-06-01'), desktop: 178, mobile: 200 },
|
||||
{ date: new Date('2024-06-02'), desktop: 470, mobile: 410 },
|
||||
{ date: new Date('2024-06-03'), desktop: 103, mobile: 160 },
|
||||
{ date: new Date('2024-06-04'), desktop: 439, mobile: 380 },
|
||||
{ date: new Date('2024-06-05'), desktop: 88, mobile: 140 },
|
||||
{ date: new Date('2024-06-06'), desktop: 294, mobile: 250 },
|
||||
{ date: new Date('2024-06-07'), desktop: 323, mobile: 370 },
|
||||
{ date: new Date('2024-06-08'), desktop: 385, mobile: 320 },
|
||||
{ date: new Date('2024-06-09'), desktop: 438, mobile: 480 },
|
||||
{ date: new Date('2024-06-10'), desktop: 155, mobile: 200 },
|
||||
{ date: new Date('2024-06-11'), desktop: 92, mobile: 150 },
|
||||
{ date: new Date('2024-06-12'), desktop: 492, mobile: 420 },
|
||||
{ date: new Date('2024-06-13'), desktop: 81, mobile: 130 },
|
||||
{ date: new Date('2024-06-14'), desktop: 426, mobile: 380 },
|
||||
{ date: new Date('2024-06-15'), desktop: 307, mobile: 350 },
|
||||
{ date: new Date('2024-06-16'), desktop: 371, mobile: 310 },
|
||||
{ date: new Date('2024-06-17'), desktop: 475, mobile: 520 },
|
||||
{ date: new Date('2024-06-18'), desktop: 107, mobile: 170 },
|
||||
{ date: new Date('2024-06-19'), desktop: 341, mobile: 290 },
|
||||
{ date: new Date('2024-06-20'), desktop: 408, mobile: 450 },
|
||||
{ date: new Date('2024-06-21'), desktop: 169, mobile: 210 },
|
||||
{ date: new Date('2024-06-22'), desktop: 317, mobile: 270 },
|
||||
{ date: new Date('2024-06-23'), desktop: 480, mobile: 530 },
|
||||
{ date: new Date('2024-06-24'), desktop: 132, mobile: 180 },
|
||||
{ date: new Date('2024-06-25'), desktop: 141, mobile: 190 },
|
||||
{ date: new Date('2024-06-26'), desktop: 434, mobile: 380 },
|
||||
{ date: new Date('2024-06-27'), desktop: 448, mobile: 490 },
|
||||
{ date: new Date('2024-06-28'), desktop: 149, mobile: 200 },
|
||||
{ date: new Date('2024-06-29'), desktop: 103, mobile: 160 },
|
||||
{ date: new Date('2024-06-30'), desktop: 446, mobile: 400 },
|
||||
]
|
||||
type Data = typeof chartData[number]
|
||||
|
||||
const chartConfig = {
|
||||
// visitors: {
|
||||
// label: 'Visitors',
|
||||
// },
|
||||
mobile: {
|
||||
label: 'Mobile',
|
||||
color: 'var(--chart-2)',
|
||||
},
|
||||
desktop: {
|
||||
label: 'Desktop',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const svgDefs = `
|
||||
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stop-color="var(--color-desktop)"
|
||||
stop-opacity="0.8"
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stop-color="var(--color-desktop)"
|
||||
stop-opacity="0.1"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stop-color="var(--color-mobile)"
|
||||
stop-opacity="0.8"
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stop-color="var(--color-mobile)"
|
||||
stop-opacity="0.1"
|
||||
/>
|
||||
</linearGradient>
|
||||
`
|
||||
|
||||
const timeRange = ref('90d')
|
||||
const filterRange = computed(() => {
|
||||
return chartData.filter((item) => {
|
||||
const date = new Date(item.date)
|
||||
const referenceDate = new Date('2024-06-30')
|
||||
let daysToSubtract = 90
|
||||
if (timeRange.value === '30d') {
|
||||
daysToSubtract = 30
|
||||
}
|
||||
else if (timeRange.value === '7d') {
|
||||
daysToSubtract = 7
|
||||
}
|
||||
const startDate = new Date(referenceDate)
|
||||
startDate.setDate(startDate.getDate() - daysToSubtract)
|
||||
return date >= startDate
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="pt-0">
|
||||
<CardHeader class="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
|
||||
<div class="grid flex-1 gap-1">
|
||||
<CardTitle>Area Chart - Interactive</CardTitle>
|
||||
<CardDescription>
|
||||
Showing total visitors for the last 3 months
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Select v-model="timeRange">
|
||||
<SelectTrigger
|
||||
class="hidden w-[160px] rounded-lg sm:ml-auto sm:flex"
|
||||
aria-label="Select a value"
|
||||
>
|
||||
<SelectValue placeholder="Last 3 months" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="rounded-xl">
|
||||
<SelectItem value="90d" class="rounded-lg">
|
||||
Last 3 months
|
||||
</SelectItem>
|
||||
<SelectItem value="30d" class="rounded-lg">
|
||||
Last 30 days
|
||||
</SelectItem>
|
||||
<SelectItem value="7d" class="rounded-lg">
|
||||
Last 7 days
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardHeader>
|
||||
<CardContent class="px-2 pt-4 sm:px-6 sm:pt-6 pb-4">
|
||||
<ChartContainer :config="chartConfig" class="aspect-auto h-[250px] w-full" :cursor="false">
|
||||
<VisXYContainer
|
||||
:data="filterRange"
|
||||
:svg-defs="svgDefs"
|
||||
:margin="{ left: -40 }"
|
||||
:y-domain="[0, 1200]"
|
||||
>
|
||||
<VisArea
|
||||
:x="(d: Data) => d.date"
|
||||
:y="[(d: Data) => d.mobile, (d: Data) => d.desktop]"
|
||||
:color="(_d: Data, i: number) => ['url(#fillMobile)', 'url(#fillDesktop)'][i]"
|
||||
:opacity="0.6"
|
||||
/>
|
||||
<VisLine
|
||||
:x="(d: Data) => d.date"
|
||||
:y="[(d: Data) => d.mobile, (d: Data) => d.mobile + d.desktop]"
|
||||
:color="(_d: Data, i: number) => [chartConfig.mobile.color, chartConfig.desktop.color][i]"
|
||||
:line-width="1"
|
||||
/>
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="(d: Data) => d.date"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
:grid-line="false"
|
||||
:num-ticks="6"
|
||||
:tick-format="(d: number, _index: number) => {
|
||||
const date = new Date(d)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}"
|
||||
/>
|
||||
<VisAxis
|
||||
type="y"
|
||||
:num-ticks="3"
|
||||
:tick-line="false"
|
||||
:domain-line="false"
|
||||
/>
|
||||
<ChartTooltip />
|
||||
<ChartCrosshair
|
||||
:template="componentToString(chartConfig, ChartTooltipContent, {
|
||||
labelFormatter: (d) => {
|
||||
return new Date(d).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
},
|
||||
})"
|
||||
:color="(_d: Data, i: number) => [chartConfig.mobile.color, chartConfig.desktop.color][i % 2]"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
|
||||
<ChartLegendContent />
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts" setup>
|
||||
import OverviewChart from './overview-chart.vue'
|
||||
import RecentSales from './recent-sales.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
Total Revenue
|
||||
</UiCardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
class="size-4 text-muted-foreground"
|
||||
>
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
$45,231.89
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+20.1% from last month
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
Subscriptions
|
||||
</UiCardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
class="size-4 text-muted-foreground"
|
||||
>
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
+2350
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+180.1% from last month
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
Sales
|
||||
</UiCardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
class="size-4 text-muted-foreground"
|
||||
>
|
||||
<rect width="20" height="14" x="2" y="5" rx="2" />
|
||||
<path d="M2 10h20" />
|
||||
</svg>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
+12,234
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+19% from last month
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
<UiCard>
|
||||
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<UiCardTitle class="text-sm font-medium">
|
||||
Active Now
|
||||
</UiCardTitle>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
class="size-4 text-muted-foreground"
|
||||
>
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
+573
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
+201 since last hour
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-7">
|
||||
<OverviewChart class="col-span-1 lg:col-span-4" />
|
||||
<UiCard class="col-span-1 lg:col-span-3">
|
||||
<UiCardHeader>
|
||||
<UiCardTitle>Recent Sales</UiCardTitle>
|
||||
<UiCardDescription>
|
||||
You made 265 sales this month.
|
||||
</UiCardDescription>
|
||||
</UiCardHeader>
|
||||
<UiCardContent>
|
||||
<RecentSales />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
|
||||
interface User {
|
||||
avatar: string
|
||||
name: string
|
||||
email: string
|
||||
amount: string
|
||||
}
|
||||
|
||||
const list = ref<User[]>([
|
||||
{ avatar: '', name: 'Olivia Martin', email: 'olivia.martin@email.com', amount: '$1,999.00' },
|
||||
{ avatar: '', name: 'Jackson Lee', email: 'jackson.lee@email.com', amount: '$39.00' },
|
||||
{ avatar: '', name: 'Isabella Nguyen', email: 'isabella.nguyen@email.com', amount: '$299.00' },
|
||||
{ avatar: '', name: 'William Kim', email: 'will@email.com', amount: '$99.00' },
|
||||
{ avatar: '', name: 'Sofia Davis', email: 'sofia.davis@email.com', amount: '$39.00' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div v-for="item in list" :key="item.name" class="flex items-center gap-4">
|
||||
<Avatar class-name="h-9 w-9">
|
||||
<AvatarImage :src="item.avatar" alt="Avatar" />
|
||||
<AvatarFallback>{{ item.name[0].toUpperCase() }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="flex flex-wrap items-center justify-between flex-1">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium leading-none">
|
||||
{{ item.name }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.email }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
{{ item.amount }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
50
monisuo-admin/src/pages/dashboard/index.vue
Normal file
50
monisuo-admin/src/pages/dashboard/index.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import OverviewContent from './components/overview-content.vue'
|
||||
|
||||
const tabs = ref([
|
||||
{ name: 'Overview', value: 'overview' },
|
||||
{ name: 'Analytics', value: 'analytics', disabled: true },
|
||||
{ name: 'Reports', value: 'reports', disabled: true },
|
||||
{ name: 'Notifications', value: 'notifications', disabled: true },
|
||||
])
|
||||
|
||||
const activeTab = ref(tabs.value[0].value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="workspace"
|
||||
description="workspace description"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
@click="() => toast('hello', {
|
||||
position: 'top-center',
|
||||
})"
|
||||
>
|
||||
{{ $t('download') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<UiTabs :default-value="activeTab" class="w-full">
|
||||
<UiTabsList>
|
||||
<UiTabsTrigger
|
||||
v-for="tab in tabs" :key="tab.value"
|
||||
:value="tab.value"
|
||||
:disabled="tab.disabled"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</UiTabsTrigger>
|
||||
</UiTabsList>
|
||||
<UiTabsContent value="overview" class="space-y-4">
|
||||
<OverviewContent />
|
||||
</UiTabsContent>
|
||||
</UiTabs>
|
||||
</BasicPage>
|
||||
</template>
|
||||
19
monisuo-admin/src/pages/index.vue
Normal file
19
monisuo-admin/src/pages/index.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Loading from '@/components/loading.vue'
|
||||
|
||||
const router = useRouter()
|
||||
router.push({ name: '/dashboard/' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center w-screen h-screen">
|
||||
<Loading />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: false
|
||||
</route>
|
||||
322
monisuo-admin/src/pages/monisuo/analytics.vue
Normal file
322
monisuo-admin/src/pages/monisuo/analytics.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import VChart from 'vue-echarts'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import {
|
||||
useGetCoinDistributionQuery,
|
||||
useGetProfitAnalysisQuery,
|
||||
useGetRiskMetricsQuery,
|
||||
useGetTradeAnalysisQuery,
|
||||
useGetUserGrowthQuery,
|
||||
} from '@/services/api/monisuo-admin.api'
|
||||
|
||||
// ========== 模块1: 盈利分析 ==========
|
||||
const { data: profitData, isLoading: profitLoading } = useGetProfitAnalysisQuery('month')
|
||||
|
||||
const profitMetrics = computed(() => {
|
||||
const data = profitData.value?.data
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return [
|
||||
{ label: '交易手续费', value: data.tradeFee, rate: data.tradeFeeRate, icon: 'lucide:percent', color: 'text-green-600' },
|
||||
{ label: '充提手续费', value: data.fundFee, rate: data.fundFeeRate, icon: 'lucide:credit-card', color: 'text-blue-600' },
|
||||
{ label: '资金利差', value: data.interestProfit, rate: data.interestRate, icon: 'lucide:trending-up', color: 'text-purple-600' },
|
||||
{ label: '本月收益', value: data.totalProfit, rate: '+18.5%', icon: 'lucide:dollar-sign', color: 'text-orange-600' },
|
||||
]
|
||||
})
|
||||
|
||||
// ========== 模块2: 交易分析 ==========
|
||||
const { data: tradeData, isLoading: tradeLoading } = useGetTradeAnalysisQuery('week')
|
||||
|
||||
const tradeAnalysisOption = computed(() => {
|
||||
const trend = tradeData.value?.data?.trend || []
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['买入', '卖出'], bottom: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
|
||||
xAxis: { type: 'category', data: trend.map((t: any) => t.date) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '买入', type: 'line', smooth: true, data: trend.map((t: any) => t.buy), itemStyle: { color: '#10b981' } },
|
||||
{ name: '卖出', type: 'line', smooth: true, data: trend.map((t: any) => t.sell), itemStyle: { color: '#ef4444' } },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 模块3: 币种分布 ==========
|
||||
const { data: coinData } = useGetCoinDistributionQuery('month')
|
||||
|
||||
const coinDistributionOption = computed(() => {
|
||||
const data = coinData.value?.data || []
|
||||
const colors = ['#f7931a', '#627eea', '#26a17b', '#9ca3af']
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {d}%' },
|
||||
legend: { orient: 'vertical', right: '5%', top: 'center' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['50%', '75%'],
|
||||
center: ['35%', '50%'],
|
||||
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, position: 'inside', formatter: '{d}%', fontSize: 11 },
|
||||
data: data.map((item: any, index: number) => ({
|
||||
value: item.amount,
|
||||
name: item.coinCode,
|
||||
itemStyle: { color: colors[index % colors.length] },
|
||||
})),
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 模块4: 用户分析 ==========
|
||||
const { data: userGrowthData } = useGetUserGrowthQuery(6)
|
||||
|
||||
const userMetrics = computed(() => {
|
||||
const data = userGrowthData.value?.data
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return [
|
||||
{ label: '新增用户', value: data.monthNewUsers, change: '+15.3%', up: true },
|
||||
{ label: '活跃用户', value: data.activeUsersToday, change: '+8.7%', up: true },
|
||||
{ label: '总用户', value: data.totalUsers, change: '+12.1%', up: true },
|
||||
{ label: '留存率', value: '68%', change: '+5.2%', up: true },
|
||||
]
|
||||
})
|
||||
|
||||
const userGrowthOption = computed(() => {
|
||||
const trend = userGrowthData.value?.data?.trend || []
|
||||
|
||||
return {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['新增', '活跃'], bottom: 0 },
|
||||
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: trend.map((t: any) => t.month) },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: '新增', type: 'line', smooth: true, data: trend.map((t: any) => t.newUsers), itemStyle: { color: '#8b5cf6' }, areaStyle: { color: 'rgba(139, 92, 246, 0.1)' } },
|
||||
{ name: '活跃', type: 'line', smooth: true, data: trend.map((t: any) => t.activeUsers), itemStyle: { color: '#06b6d4' }, areaStyle: { color: 'rgba(6, 182, 212, 0.1)' } },
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 模块5: 风险指标 ==========
|
||||
const { data: riskData } = useGetRiskMetricsQuery()
|
||||
|
||||
const riskMetrics = computed(() => {
|
||||
const data = riskData.value?.data
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return [
|
||||
{ label: '大额交易', value: data.largeTransactions, threshold: data.largeTransactionThreshold, status: 'normal', color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
||||
{ label: '异常提现', value: data.abnormalWithdrawals, threshold: data.abnormalWithdrawalThreshold, status: data.abnormalWithdrawals > 0 ? 'warning' : 'normal', color: 'text-red-600', bgColor: 'bg-red-50' },
|
||||
{ label: '待审KYC', value: data.pendingKyc, threshold: '身份验证', status: 'normal', color: 'text-yellow-600', bgColor: 'bg-yellow-50' },
|
||||
{ label: '冻结账户', value: data.frozenAccounts, threshold: '风险账户', status: 'normal', color: 'text-gray-600', bgColor: 'bg-gray-50' },
|
||||
]
|
||||
})
|
||||
|
||||
// ========== 模块6: 决策建议 ==========
|
||||
const suggestions = computed(() => {
|
||||
const data = profitData.value?.data
|
||||
const risk = riskData.value?.data
|
||||
|
||||
const items = []
|
||||
|
||||
// 基于盈利情况
|
||||
if (data && data.totalProfit > 0) {
|
||||
items.push({
|
||||
type: 'success',
|
||||
icon: 'lucide:check-circle',
|
||||
title: '盈利健康',
|
||||
desc: `本月总收益 ¥${data.totalProfit.toFixed(2)},收益趋势良好`,
|
||||
color: 'border-green-500 bg-green-50 dark:bg-green-950/20',
|
||||
})
|
||||
}
|
||||
|
||||
// 基于风险情况
|
||||
if (risk && risk.abnormalWithdrawals > 0) {
|
||||
items.push({
|
||||
type: 'warning',
|
||||
icon: 'lucide:alert-circle',
|
||||
title: '关注风险',
|
||||
desc: `检测到 ${risk.abnormalWithdrawals} 笔异常提现申请,建议加强风控审核`,
|
||||
color: 'border-yellow-500 bg-yellow-50 dark:bg-yellow-950/20',
|
||||
})
|
||||
}
|
||||
|
||||
// 基于交易情况
|
||||
if (tradeData.value?.data) {
|
||||
items.push({
|
||||
type: 'info',
|
||||
icon: 'lucide:info',
|
||||
title: '交易分析',
|
||||
desc: '本周交易活跃,用户参与度良好',
|
||||
color: 'border-blue-500 bg-blue-50 dark:bg-blue-950/20',
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const isLoading = computed(() => profitLoading.value || tradeLoading.value)
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (!value)
|
||||
return '¥0'
|
||||
if (value >= 10000)
|
||||
return `¥${(value / 10000).toFixed(1)}万`
|
||||
return `¥${value.toLocaleString()}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="业务分析" description="多维度数据洞察,辅助业务决策">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<UiSpinner class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- 模块1: 盈利分析 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:dollar-sign" class="size-4" />
|
||||
盈利分析
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UiCard v-for="item in profitMetrics" :key="item.label" class="hover:shadow-sm transition-shadow">
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<Icon :icon="item.icon" class="size-5" :class="item.color" />
|
||||
<span class="text-xs text-muted-foreground">{{ item.rate }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-2xl font-bold font-mono mt-1" :class="item.color">
|
||||
{{ formatCurrency(item.value) }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 模块2: 交易分析 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:trending-up" class="size-4" />
|
||||
交易分析
|
||||
</h2>
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<VChart :option="tradeAnalysisOption" autoresize style="height: 280px" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</section>
|
||||
|
||||
<!-- 模块3: 币种分布 + 用户分析 -->
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:pie-chart" class="size-4" />
|
||||
币种交易分布
|
||||
</h2>
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<VChart :option="coinDistributionOption" autoresize style="height: 240px" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</section>
|
||||
|
||||
<section class="lg:col-span-2">
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:users" class="size-4" />
|
||||
用户分析
|
||||
</h2>
|
||||
<div class="grid gap-4">
|
||||
<div class="grid gap-3 sm:grid-cols-4">
|
||||
<UiCard v-for="item in userMetrics" :key="item.label" class="hover:shadow-sm transition-shadow">
|
||||
<UiCardContent class="pt-4 pb-4">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-xl font-bold mt-1">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
<p class="text-xs mt-1" :class="item.up ? 'text-green-600' : 'text-red-600'">
|
||||
{{ item.change }}
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<VChart :option="userGrowthOption" autoresize style="height: 180px" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 模块4: 风险指标 + 决策建议 -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:shield" class="size-4" />
|
||||
风险指标
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<UiCard v-for="item in riskMetrics" :key="item.label" class="hover:shadow-sm transition-shadow">
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<span class="text-xs text-muted-foreground">{{ item.threshold }}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-3xl font-bold" :class="item.color">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
<UiBadge v-if="item.status === 'warning'" variant="destructive">
|
||||
需关注
|
||||
</UiBadge>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:lightbulb" class="size-4" />
|
||||
决策建议
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="item in suggestions"
|
||||
:key="item.title"
|
||||
class="p-4 rounded-lg border-l-4"
|
||||
:class="item.color"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon :icon="item.icon" class="size-5 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{{ item.desc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
353
monisuo-admin/src/pages/monisuo/coins.vue
Normal file
353
monisuo-admin/src/pages/monisuo/coins.vue
Normal file
@@ -0,0 +1,353 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { Coin } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { useGetCoinListQuery, useSaveCoinMutation, useUpdateCoinPriceMutation, useUpdateCoinStatusMutation } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
const { data, isLoading, refetch } = useGetCoinListQuery()
|
||||
const saveMutation = useSaveCoinMutation()
|
||||
const priceMutation = useUpdateCoinPriceMutation()
|
||||
const statusMutation = useUpdateCoinStatusMutation()
|
||||
|
||||
const coins = computed(() => data.value?.data?.list || [])
|
||||
|
||||
const editingCoin = ref<Partial<Coin>>({})
|
||||
const showEditDialog = ref(false)
|
||||
const showPriceDialog = ref(false)
|
||||
const priceInput = ref<number>(0)
|
||||
const editingCode = ref('')
|
||||
|
||||
// 表单验证
|
||||
const formErrors = ref<{ code?: string, name?: string }>({})
|
||||
|
||||
function validateForm(): boolean {
|
||||
formErrors.value = {}
|
||||
|
||||
if (!editingCoin.value.code?.trim()) {
|
||||
formErrors.value.code = '请输入币种代码'
|
||||
return false
|
||||
}
|
||||
if (!editingCoin.value.name?.trim()) {
|
||||
formErrors.value.name = '请输入币种名称'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function openEditDialog(coin?: Coin) {
|
||||
if (coin) {
|
||||
editingCoin.value = { ...coin }
|
||||
}
|
||||
else {
|
||||
editingCoin.value = { priceType: 2, status: 1, price: 0 }
|
||||
}
|
||||
formErrors.value = {}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
async function saveCoin() {
|
||||
if (!validateForm())
|
||||
return
|
||||
|
||||
try {
|
||||
await saveMutation.mutateAsync(editingCoin.value as Coin)
|
||||
toast.success(editingCoin.value.id ? '币种已更新' : '币种已添加')
|
||||
showEditDialog.value = false
|
||||
refetch()
|
||||
}
|
||||
catch (e: any) {
|
||||
toast.error(e.response?.data?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openPriceDialog(coin: Coin) {
|
||||
editingCode.value = coin.code
|
||||
priceInput.value = coin.price
|
||||
showPriceDialog.value = true
|
||||
}
|
||||
|
||||
async function updatePrice() {
|
||||
if (priceInput.value <= 0) {
|
||||
toast.error('请输入有效价格')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await priceMutation.mutateAsync({ code: editingCode.value, price: priceInput.value })
|
||||
toast.success('价格已更新')
|
||||
showPriceDialog.value = false
|
||||
refetch()
|
||||
}
|
||||
catch (e: any) {
|
||||
toast.error(e.response?.data?.msg || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(coin: Coin) {
|
||||
const newStatus = coin.status === 1 ? 0 : 1
|
||||
const action = newStatus === 1 ? '上架' : '下架'
|
||||
|
||||
try {
|
||||
await statusMutation.mutateAsync({ coinId: coin.id, status: newStatus })
|
||||
toast.success(`已${action} ${coin.code}`)
|
||||
}
|
||||
catch (e: any) {
|
||||
toast.error(e.response?.data?.msg || `${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
if (!price)
|
||||
return '0.00'
|
||||
return price >= 1 ? price.toLocaleString(undefined, { maximumFractionDigits: 2 }) : price.toFixed(6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="币种管理" description="管理交易币种">
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-end">
|
||||
<UiButton @click="openEditDialog()">
|
||||
<Icon icon="lucide:plus" class="size-4 mr-2" />
|
||||
新增币种
|
||||
</UiButton>
|
||||
</div>
|
||||
|
||||
<!-- PC端表格 -->
|
||||
<UiCard class="hidden md:block overflow-x-auto p-4"">
|
||||
<UiTable>
|
||||
<UiTableHeader>
|
||||
<UiTableRow>
|
||||
<UiTableHead class="w-[80px]">
|
||||
ID
|
||||
</UiTableHead>
|
||||
<UiTableHead>代码</UiTableHead>
|
||||
<UiTableHead>名称</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
价格
|
||||
</UiTableHead>
|
||||
<UiTableHead>价格类型</UiTableHead>
|
||||
<UiTableHead>状态</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
操作
|
||||
</UiTableHead>
|
||||
</UiTableRow>
|
||||
</UiTableHeader>
|
||||
<UiTableBody>
|
||||
<UiTableRow v-if="isLoading">
|
||||
<UiTableCell :col-span="7" 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>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-for="coin in coins" :key="coin.id">
|
||||
<UiTableCell>{{ coin.id }}</UiTableCell>
|
||||
<UiTableCell class="font-mono font-medium">
|
||||
{{ coin.code }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>{{ coin.name }}</UiTableCell>
|
||||
<UiTableCell class="text-right font-mono">
|
||||
${{ formatPrice(coin.price) }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge variant="outline">
|
||||
<Icon v-if="coin.priceType === 1" icon="lucide:zap" class="size-3 mr-1" />
|
||||
{{ coin.priceType === 1 ? '实时' : '手动' }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge :variant="coin.status === 1 ? 'default' : 'secondary'">
|
||||
{{ coin.status === 1 ? '上架' : '下架' }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<UiButton size="sm" variant="ghost" @click="openEditDialog(coin)">
|
||||
<Icon icon="lucide:pencil" class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="coin.priceType === 1"
|
||||
:title="coin.priceType === 1 ? '实时价格不支持手动调整' : '调整价格'"
|
||||
@click="openPriceDialog(coin)"
|
||||
>
|
||||
<Icon icon="lucide:dollar-sign" class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="statusMutation.isPending.value"
|
||||
@click="toggleStatus(coin)"
|
||||
>
|
||||
<Icon :icon="coin.status === 1 ? 'lucide:arrow-down' : 'lucide:arrow-up'" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
</UiTableBody>
|
||||
</UiTable>
|
||||
</UiCard>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div v-if="isLoading" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</div>
|
||||
<template v-else-if="coins.length > 0">
|
||||
<UiCard v-for="coin in coins" :key="coin.id" class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono font-bold">{{ coin.code }}</span>
|
||||
<UiBadge variant="outline" class="text-xs">
|
||||
{{ coin.priceType === 1 ? '实时' : '手动' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ coin.name }}
|
||||
</div>
|
||||
</div>
|
||||
<UiBadge :variant="coin.status === 1 ? 'default' : 'secondary'">
|
||||
{{ coin.status === 1 ? '上架' : '下架' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<div class="text-xl font-mono font-bold text-green-600 dark:text-green-400">
|
||||
${{ formatPrice(coin.price) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<UiButton size="sm" variant="outline" class="flex-1" @click="openEditDialog(coin)">
|
||||
编辑
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="coin.priceType === 1"
|
||||
@click="openPriceDialog(coin)"
|
||||
>
|
||||
调价
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="statusMutation.isPending.value"
|
||||
@click="toggleStatus(coin)"
|
||||
>
|
||||
{{ coin.status === 1 ? '下架' : '上架' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑币种弹窗 -->
|
||||
<UiDialog v-model:open="showEditDialog">
|
||||
<UiDialogContent class="max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>{{ editingCoin.id ? '编辑币种' : '新增币种' }}</UiDialogTitle>
|
||||
</UiDialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<UiLabel>代码 <span class="text-red-500">*</span></UiLabel>
|
||||
<UiInput
|
||||
v-model="editingCoin.code"
|
||||
placeholder="BTC"
|
||||
:class="{ 'border-red-500': formErrors.code }"
|
||||
@input="formErrors.code = undefined"
|
||||
/>
|
||||
<p v-if="formErrors.code" class="text-sm text-red-500">
|
||||
{{ formErrors.code }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<UiLabel>名称 <span class="text-red-500">*</span></UiLabel>
|
||||
<UiInput
|
||||
v-model="editingCoin.name"
|
||||
placeholder="比特币"
|
||||
:class="{ 'border-red-500': formErrors.name }"
|
||||
@input="formErrors.name = undefined"
|
||||
/>
|
||||
<p v-if="formErrors.name" class="text-sm text-red-500">
|
||||
{{ formErrors.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<UiLabel>价格类型</UiLabel>
|
||||
<UiSelect v-model="editingCoin.priceType">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem :value="1">
|
||||
实时(从市场获取)
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="2">
|
||||
手动(自定义价格)
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div v-if="editingCoin.priceType === 2" class="grid gap-2">
|
||||
<UiLabel>初始价格 ($)</UiLabel>
|
||||
<UiInput v-model.number="editingCoin.price" type="number" step="0.000001" placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="showEditDialog = false">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="saveMutation.isPending.value" @click="saveCoin">
|
||||
<UiSpinner v-if="saveMutation.isPending.value" class="mr-2" />
|
||||
保存
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
|
||||
<!-- 调价弹窗 -->
|
||||
<UiDialog v-model:open="showPriceDialog">
|
||||
<UiDialogContent class="max-w-sm">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>调整价格 - {{ editingCode }}</UiDialogTitle>
|
||||
</UiDialogHeader>
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<UiLabel>新价格 ($)</UiLabel>
|
||||
<UiInput
|
||||
v-model.number="priceInput"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
placeholder="0.00"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="showPriceDialog = false">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton :disabled="priceMutation.isPending.value" @click="updatePrice">
|
||||
<UiSpinner v-if="priceMutation.isPending.value" class="mr-2" />
|
||||
确认
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
313
monisuo-admin/src/pages/monisuo/dashboard.vue
Normal file
313
monisuo-admin/src/pages/monisuo/dashboard.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { useGetFinanceOverviewQuery } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const { data: overviewData, isLoading } = useGetFinanceOverviewQuery()
|
||||
const overview = computed(() => overviewData.value?.data)
|
||||
|
||||
// ========== 模块1: 资金概览 ==========
|
||||
const fundMetrics = computed(() => [
|
||||
{
|
||||
label: '在管资金',
|
||||
value: overview.value?.fundBalance || 0,
|
||||
icon: 'lucide:wallet',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950',
|
||||
},
|
||||
{
|
||||
label: '交易账户',
|
||||
value: overview.value?.tradeValue || 0,
|
||||
icon: 'lucide:bar-chart-3',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-950',
|
||||
},
|
||||
{
|
||||
label: '总资产',
|
||||
value: (overview.value?.fundBalance || 0) + (overview.value?.tradeValue || 0),
|
||||
icon: 'lucide:landmark',
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-950',
|
||||
},
|
||||
])
|
||||
|
||||
// ========== 模块2: 资金流动 ==========
|
||||
const flowMetrics = computed(() => [
|
||||
{
|
||||
label: '累计充值',
|
||||
value: overview.value?.totalDeposit || 0,
|
||||
icon: 'lucide:arrow-down-circle',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
trend: '+12.5%',
|
||||
},
|
||||
{
|
||||
label: '累计提现',
|
||||
value: overview.value?.totalWithdraw || 0,
|
||||
icon: 'lucide:arrow-up-circle',
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50 dark:bg-red-950',
|
||||
trend: '+8.3%',
|
||||
},
|
||||
{
|
||||
label: '净流入',
|
||||
value: (overview.value?.totalDeposit || 0) - (overview.value?.totalWithdraw || 0),
|
||||
icon: 'lucide:trending-up',
|
||||
color: 'text-emerald-600',
|
||||
bgColor: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
trend: '+15.2%',
|
||||
},
|
||||
])
|
||||
|
||||
// ========== 模块3: 资金趋势图 ==========
|
||||
const trendChartOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['充值', '提现'], bottom: 0, top: 'auto' },
|
||||
grid: { left: '3%', right: '4%', bottom: '15%', top: '5%', containLabel: true },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['1月', '2月', '3月', '4月', '5月', '6月'],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: { formatter: '¥{value}K' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '充值',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [320, 302, 301, 334, 390, 430],
|
||||
itemStyle: { color: '#10b981' },
|
||||
areaStyle: { color: 'rgba(16, 185, 129, 0.1)' },
|
||||
},
|
||||
{
|
||||
name: '提现',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: [120, 132, 101, 134, 90, 230],
|
||||
itemStyle: { color: '#ef4444' },
|
||||
areaStyle: { color: 'rgba(239, 68, 68, 0.1)' },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// ========== 模块4: 资金分布 ==========
|
||||
const distributionOption = computed(() => ({
|
||||
tooltip: { trigger: 'item', formatter: '{b}: {d}%' },
|
||||
legend: { orient: 'vertical', right: '5%', top: 'center' },
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['50%', '75%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: true, position: 'inside', formatter: '{d}%', fontSize: 12 },
|
||||
data: [
|
||||
{ value: overview.value?.fundBalance || 50, name: '在管资金', itemStyle: { color: '#3b82f6' } },
|
||||
{ value: overview.value?.tradeValue || 30, name: '交易账户', itemStyle: { color: '#8b5cf6' } },
|
||||
{ value: 20, name: '冻结资金', itemStyle: { color: '#f59e0b' } },
|
||||
],
|
||||
}],
|
||||
}))
|
||||
|
||||
// ========== 模块5: 运营指标 ==========
|
||||
const operationMetrics = computed(() => [
|
||||
{ label: '用户总数', value: overview.value?.userCount || 0, icon: 'lucide:users' },
|
||||
{ label: '待审批', value: overview.value?.pendingCount || 0, icon: 'lucide:clock' },
|
||||
])
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (value >= 10000)
|
||||
return `¥${(value / 10000).toFixed(1)}万`
|
||||
return `¥${value.toLocaleString()}`
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="数据看板" description="核心业务数据一目了然">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-20">
|
||||
<UiSpinner class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- 模块1: 资金概览 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:wallet" class="size-4" />
|
||||
资金概览
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<UiCard v-for="item in fundMetrics" :key="item.label" class="hover:shadow-sm transition-shadow">
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="p-2.5 rounded-lg" :class="[item.bgColor]">
|
||||
<Icon :icon="item.icon" class="size-5" :class="item.color" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-2xl font-bold font-mono" :class="item.color">
|
||||
{{ formatCurrency(item.value) }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 模块2: 资金流动 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:git-compare" class="size-4" />
|
||||
资金流动
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<UiCard v-for="item in flowMetrics" :key="item.label" class="hover:shadow-sm transition-shadow">
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="p-2.5 rounded-lg" :class="[item.bgColor]">
|
||||
<Icon :icon="item.icon" class="size-5" :class="item.color" />
|
||||
</div>
|
||||
<span class="text-xs font-medium text-green-600">{{ item.trend }}</span>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-2xl font-bold font-mono" :class="item.color">
|
||||
{{ formatCurrency(item.value) }}
|
||||
</p>
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 模块3+4: 图表区域 -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<!-- 资金趋势 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:trending-up" class="size-4" />
|
||||
资金趋势
|
||||
</h2>
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<VChart :option="trendChartOption" autoresize style="height: 260px" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</section>
|
||||
|
||||
<!-- 资金分布 -->
|
||||
<section>
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:pie-chart" class="size-4" />
|
||||
资金分布
|
||||
</h2>
|
||||
<UiCard>
|
||||
<UiCardContent class="pt-6">
|
||||
<VChart :option="distributionOption" autoresize style="height: 260px" />
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 模块5: 运营指标 + 快捷入口 -->
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<!-- 运营指标 -->
|
||||
<section class="lg:col-span-1">
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:activity" class="size-4" />
|
||||
运营指标
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<UiCard v-for="item in operationMetrics" :key="item.label" class="hover:shadow-sm transition-shadow">
|
||||
<UiCardContent class="pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-2xl font-bold mt-1">
|
||||
{{ item.value }}
|
||||
</p>
|
||||
</div>
|
||||
<Icon :icon="item.icon" class="size-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<section class="lg:col-span-2">
|
||||
<h2 class="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
|
||||
<Icon icon="lucide:zap" class="size-4" />
|
||||
快捷入口
|
||||
</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<UiCard
|
||||
class="cursor-pointer hover:shadow-md hover:border-primary/50 transition-all"
|
||||
@click="navigateTo('/monisuo/users')"
|
||||
>
|
||||
<UiCardContent class="pt-6 text-center">
|
||||
<Icon icon="lucide:users" class="size-8 mx-auto mb-2 text-blue-600" />
|
||||
<p class="font-medium">
|
||||
用户管理
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard
|
||||
class="cursor-pointer hover:shadow-md hover:border-primary/50 transition-all"
|
||||
@click="navigateTo('/monisuo/coins')"
|
||||
>
|
||||
<UiCardContent class="pt-6 text-center">
|
||||
<Icon icon="lucide:coins" class="size-8 mx-auto mb-2 text-yellow-600" />
|
||||
<p class="font-medium">
|
||||
币种管理
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard
|
||||
class="cursor-pointer hover:shadow-md hover:border-primary/50 transition-all"
|
||||
@click="navigateTo('/monisuo/orders')"
|
||||
>
|
||||
<UiCardContent class="pt-6 text-center">
|
||||
<Icon icon="lucide:clipboard-check" class="size-8 mx-auto mb-2 text-green-600" />
|
||||
<p class="font-medium">
|
||||
订单审批
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
|
||||
<UiCard
|
||||
class="cursor-pointer hover:shadow-md hover:border-primary/50 transition-all"
|
||||
@click="navigateTo('/monisuo/analytics')"
|
||||
>
|
||||
<UiCardContent class="pt-6 text-center">
|
||||
<Icon icon="lucide:trending-up" class="size-8 mx-auto mb-2 text-purple-600" />
|
||||
<p class="font-medium">
|
||||
业务分析
|
||||
</p>
|
||||
</UiCardContent>
|
||||
</UiCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
628
monisuo-admin/src/pages/monisuo/orders.vue
Normal file
628
monisuo-admin/src/pages/monisuo/orders.vue
Normal file
@@ -0,0 +1,628 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { OrderFund } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { useApproveOrderMutation, useGetAllOrdersQuery, useGetPendingOrdersQuery } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
const pageNum = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const activeTab = ref('pending')
|
||||
|
||||
// 筛选条件
|
||||
const filterType = ref<number | string>('all')
|
||||
const filterStatus = ref<number | string>('all')
|
||||
|
||||
const { data: pendingData, isLoading: pendingLoading, refetch: refetchPending } = useGetPendingOrdersQuery({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
|
||||
const { data: allData, isLoading: allLoading, refetch: refetchAll } = useGetAllOrdersQuery({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
type: filterType.value === 'all' ? undefined : filterType.value as number,
|
||||
status: filterStatus.value === 'all' ? undefined : filterStatus.value as number,
|
||||
})
|
||||
|
||||
const approveMutation = useApproveOrderMutation()
|
||||
|
||||
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 showApproveDialog = ref(false)
|
||||
const showDetailDialog = ref(false)
|
||||
const currentOrder = ref<OrderFund | null>(null)
|
||||
const approveStatus = ref(2)
|
||||
const rejectReason = ref('')
|
||||
const adminRemark = ref('')
|
||||
|
||||
function viewOrderDetail(order: OrderFund) {
|
||||
currentOrder.value = order
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
function openApproveDialog(order: OrderFund, status: number) {
|
||||
currentOrder.value = order
|
||||
approveStatus.value = status
|
||||
rejectReason.value = ''
|
||||
adminRemark.value = ''
|
||||
showApproveDialog.value = true
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
if (!currentOrder.value)
|
||||
return
|
||||
|
||||
const action = approveStatus.value === 2 ? '通过' : '驳回'
|
||||
|
||||
try {
|
||||
await approveMutation.mutateAsync({
|
||||
orderNo: currentOrder.value.orderNo,
|
||||
status: approveStatus.value,
|
||||
rejectReason: rejectReason.value || undefined,
|
||||
adminRemark: adminRemark.value || undefined,
|
||||
})
|
||||
|
||||
toast.success(`订单已${action}`)
|
||||
showApproveDialog.value = false
|
||||
refetchPending()
|
||||
refetchAll()
|
||||
}
|
||||
catch (e: any) {
|
||||
toast.error(e.response?.data?.msg || `${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pageNum.value = page
|
||||
refetchPending()
|
||||
refetchAll()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size: unknown) {
|
||||
if (size === null || size === undefined)
|
||||
return
|
||||
pageSize.value = Number(size)
|
||||
pageNum.value = 1
|
||||
refetchPending()
|
||||
refetchAll()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterType.value = 'all'
|
||||
filterStatus.value = 'all'
|
||||
pageNum.value = 1
|
||||
refetchAll()
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function getStatusVariant(status: number): 'default' | 'secondary' | 'destructive' {
|
||||
if (status === 1)
|
||||
return 'secondary'
|
||||
if (status === 2)
|
||||
return 'default'
|
||||
return 'destructive'
|
||||
}
|
||||
|
||||
function getStatusText(status: number): string {
|
||||
if (status === 1)
|
||||
return '待审批'
|
||||
if (status === 2)
|
||||
return '已通过'
|
||||
return '已驳回'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="订单管理" description="审批充提订单">
|
||||
<div class="space-y-4">
|
||||
<UiTabs v-model="activeTab">
|
||||
<UiTabsList>
|
||||
<UiTabsTrigger value="pending">
|
||||
待审批
|
||||
<UiBadge v-if="pendingTotal > 0" variant="destructive" class="ml-2">
|
||||
{{ pendingTotal }}
|
||||
</UiBadge>
|
||||
</UiTabsTrigger>
|
||||
<UiTabsTrigger value="all">
|
||||
全部订单
|
||||
</UiTabsTrigger>
|
||||
</UiTabsList>
|
||||
|
||||
<!-- 待审批订单 -->
|
||||
<UiTabsContent value="pending" class="space-y-4">
|
||||
<!-- PC端表格 -->
|
||||
<UiCard class="hidden md:block overflow-x-auto p-4"">
|
||||
<UiTable>
|
||||
<UiTableHeader>
|
||||
<UiTableRow>
|
||||
<UiTableHead>订单号</UiTableHead>
|
||||
<UiTableHead>用户</UiTableHead>
|
||||
<UiTableHead>类型</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
金额
|
||||
</UiTableHead>
|
||||
<UiTableHead class="hidden lg:table-cell">
|
||||
时间
|
||||
</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
操作
|
||||
</UiTableHead>
|
||||
</UiTableRow>
|
||||
</UiTableHeader>
|
||||
<UiTableBody>
|
||||
<UiTableRow v-if="pendingLoading">
|
||||
<UiTableCell :col-span="6" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-else-if="pendingOrders.length === 0">
|
||||
<UiTableCell :col-span="6" class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:inbox" class="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无待审批订单</p>
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-for="order in pendingOrders" :key="order.id">
|
||||
<UiTableCell class="font-mono text-xs">
|
||||
{{ order.orderNo }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>{{ order.username }}</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge :variant="order.type === 1 ? 'default' : 'destructive'">
|
||||
<Icon :icon="order.type === 1 ? 'lucide:arrow-down-left' : 'lucide:arrow-up-right'" class="size-3 mr-1" />
|
||||
{{ order.type === 1 ? '充值' : '提现' }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell class="text-right font-mono font-medium">
|
||||
¥{{ formatAmount(order.amount) }}
|
||||
</UiTableCell>
|
||||
<UiTableCell class="hidden lg:table-cell text-muted-foreground text-sm">
|
||||
{{ order.createTime }}
|
||||
</UiTableCell>
|
||||
<UiTableCell class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
<UiButton size="sm" variant="ghost" @click="viewOrderDetail(order)">
|
||||
<Icon icon="lucide:eye" class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
:disabled="approveMutation.isPending.value"
|
||||
@click="openApproveDialog(order, 2)"
|
||||
>
|
||||
通过
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
:disabled="approveMutation.isPending.value"
|
||||
@click="openApproveDialog(order, 3)"
|
||||
>
|
||||
驳回
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
</UiTableBody>
|
||||
</UiTable>
|
||||
</UiCard>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div v-if="pendingLoading" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</div>
|
||||
<template v-else-if="pendingOrders.length > 0">
|
||||
<UiCard v-for="order in pendingOrders" :key="order.id" class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="font-mono text-xs text-muted-foreground">
|
||||
{{ order.orderNo }}
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
{{ order.username }}
|
||||
</div>
|
||||
</div>
|
||||
<UiBadge :variant="order.type === 1 ? 'default' : 'destructive'">
|
||||
{{ order.type === 1 ? '充值' : '提现' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<div class="text-xl font-mono font-bold" :class="order.type === 1 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ order.type === 1 ? '+' : '-' }}¥{{ formatAmount(order.amount) }}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{{ order.createTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<UiButton size="sm" class="flex-1" @click="openApproveDialog(order, 2)">
|
||||
通过
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="destructive" class="flex-1" @click="openApproveDialog(order, 3)">
|
||||
驳回
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Icon icon="lucide:inbox" class="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无待审批订单</p>
|
||||
</div>
|
||||
</div>
|
||||
</UiTabsContent>
|
||||
|
||||
<!-- 全部订单 -->
|
||||
<UiTabsContent value="all" class="space-y-4">
|
||||
<!-- 筛选条件 -->
|
||||
<UiCard class="p-4">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="w-full sm:w-[140px] grid gap-2">
|
||||
<UiLabel>类型</UiLabel>
|
||||
<UiSelect v-model="filterType">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="全部" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
全部
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="1">
|
||||
充值
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="2">
|
||||
提现
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="w-full sm:w-[140px] grid gap-2">
|
||||
<UiLabel>状态</UiLabel>
|
||||
<UiSelect v-model="filterStatus">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="全部" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
全部
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="1">
|
||||
待审批
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="2">
|
||||
已通过
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="3">
|
||||
已驳回
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<UiButton variant="outline" @click="resetFilters">
|
||||
重置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<!-- PC端表格 -->
|
||||
<UiCard class="hidden md:block overflow-x-auto p-4"">
|
||||
<UiTable>
|
||||
<UiTableHeader>
|
||||
<UiTableRow>
|
||||
<UiTableHead>订单号</UiTableHead>
|
||||
<UiTableHead>用户</UiTableHead>
|
||||
<UiTableHead>类型</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
金额
|
||||
</UiTableHead>
|
||||
<UiTableHead>状态</UiTableHead>
|
||||
<UiTableHead class="hidden xl:table-cell">
|
||||
时间
|
||||
</UiTableHead>
|
||||
<UiTableHead class="hidden lg:table-cell">
|
||||
备注
|
||||
</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
操作
|
||||
</UiTableHead>
|
||||
</UiTableRow>
|
||||
</UiTableHeader>
|
||||
<UiTableBody>
|
||||
<UiTableRow v-if="allLoading">
|
||||
<UiTableCell :col-span="8" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-else-if="allOrders.length === 0">
|
||||
<UiTableCell :col-span="8" class="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-for="order in allOrders" :key="order.id">
|
||||
<UiTableCell class="font-mono text-xs">
|
||||
{{ order.orderNo }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>{{ order.username }}</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge :variant="order.type === 1 ? 'default' : 'destructive'">
|
||||
{{ order.type === 1 ? '充值' : '提现' }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell class="text-right font-mono">
|
||||
¥{{ formatAmount(order.amount) }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge :variant="getStatusVariant(order.status)">
|
||||
{{ getStatusText(order.status) }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell class="hidden xl:table-cell text-muted-foreground text-sm">
|
||||
{{ order.createTime }}
|
||||
</UiTableCell>
|
||||
<UiTableCell class="hidden lg:table-cell text-sm text-muted-foreground max-w-[150px] truncate">
|
||||
{{ order.rejectReason || order.adminRemark || '-' }}
|
||||
</UiTableCell>
|
||||
<UiTableCell class="text-right">
|
||||
<UiButton size="sm" variant="ghost" @click="viewOrderDetail(order)">
|
||||
<Icon icon="lucide:eye" class="size-4" />
|
||||
</UiButton>
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
</UiTableBody>
|
||||
</UiTable>
|
||||
</UiCard>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div v-if="allLoading" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</div>
|
||||
<template v-else-if="allOrders.length > 0">
|
||||
<UiCard v-for="order in allOrders" :key="order.id" class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="font-mono text-xs text-muted-foreground">
|
||||
{{ order.orderNo }}
|
||||
</div>
|
||||
<div class="font-medium">
|
||||
{{ order.username }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<UiBadge :variant="order.type === 1 ? 'default' : 'destructive'" class="mb-1">
|
||||
{{ order.type === 1 ? '充值' : '提现' }}
|
||||
</UiBadge>
|
||||
<UiBadge :variant="getStatusVariant(order.status)" class="block">
|
||||
{{ getStatusText(order.status) }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t">
|
||||
<div class="text-xl font-mono font-bold">
|
||||
¥{{ formatAmount(order.amount) }}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
{{ order.createTime }}
|
||||
</div>
|
||||
<div v-if="order.rejectReason || order.adminRemark" class="text-sm text-muted-foreground mt-1">
|
||||
备注: {{ order.rejectReason || order.adminRemark }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<UiButton size="sm" variant="outline" class="w-full" @click="viewOrderDetail(order)">
|
||||
查看详情
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
</UiTabsContent>
|
||||
</UiTabs>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="currentTotal > 0" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ currentTotal }} 条记录
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">每页</span>
|
||||
<UiSelect :model-value="`${pageSize}`" @update:model-value="handlePageSizeChange">
|
||||
<UiSelectTrigger class="h-8 w-[70px]">
|
||||
<UiSelectValue />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="10">
|
||||
10
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="20">
|
||||
20
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="50">
|
||||
50
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<span class="text-sm">条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="pageNum <= 1"
|
||||
@click="handlePageChange(pageNum - 1)"
|
||||
>
|
||||
<Icon icon="lucide:chevron-left" class="size-4" />
|
||||
</UiButton>
|
||||
<span class="text-sm min-w-[80px] text-center">
|
||||
{{ pageNum }} / {{ totalPages }}
|
||||
</span>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="pageNum >= totalPages"
|
||||
@click="handlePageChange(pageNum + 1)"
|
||||
>
|
||||
<Icon icon="lucide:chevron-right" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单详情弹窗 -->
|
||||
<UiDialog v-model:open="showDetailDialog">
|
||||
<UiDialogContent class="max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>订单详情</UiDialogTitle>
|
||||
</UiDialogHeader>
|
||||
<div v-if="currentOrder" class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||
<div class="text-muted-foreground">
|
||||
订单号
|
||||
</div>
|
||||
<div class="col-span-2 font-mono">
|
||||
{{ currentOrder.orderNo }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
用户
|
||||
</div>
|
||||
<div class="col-span-2 font-medium">
|
||||
{{ currentOrder.username }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
类型
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<UiBadge :variant="currentOrder.type === 1 ? 'default' : 'destructive'">
|
||||
{{ currentOrder.type === 1 ? '充值' : '提现' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
金额
|
||||
</div>
|
||||
<div class="col-span-2 font-mono font-bold text-lg">
|
||||
¥{{ formatAmount(currentOrder.amount) }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
状态
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<UiBadge :variant="getStatusVariant(currentOrder.status)">
|
||||
{{ getStatusText(currentOrder.status) }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
创建时间
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ currentOrder.createTime }}
|
||||
</div>
|
||||
|
||||
<template v-if="currentOrder.rejectReason">
|
||||
<div class="text-muted-foreground text-red-500">
|
||||
驳回原因
|
||||
</div>
|
||||
<div class="col-span-2 text-red-500">
|
||||
{{ currentOrder.rejectReason }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="currentOrder.adminRemark">
|
||||
<div class="text-muted-foreground">
|
||||
管理员备注
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ currentOrder.adminRemark }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<template v-if="currentOrder?.status === 1">
|
||||
<UiButton variant="outline" @click="showDetailDialog = false">
|
||||
关闭
|
||||
</UiButton>
|
||||
<UiButton @click="openApproveDialog(currentOrder!, 2); showDetailDialog = false">
|
||||
通过
|
||||
</UiButton>
|
||||
<UiButton variant="destructive" @click="openApproveDialog(currentOrder!, 3); showDetailDialog = false">
|
||||
驳回
|
||||
</UiButton>
|
||||
</template>
|
||||
<UiButton v-else variant="outline" @click="showDetailDialog = false">
|
||||
关闭
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
|
||||
<!-- 审批弹窗 -->
|
||||
<UiDialog v-model:open="showApproveDialog">
|
||||
<UiDialogContent class="max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>{{ approveStatus === 2 ? '通过订单' : '驳回订单' }}</UiDialogTitle>
|
||||
</UiDialogHeader>
|
||||
<div v-if="currentOrder" class="grid gap-4 py-4">
|
||||
<div class="p-3 rounded-lg bg-muted/50 text-sm">
|
||||
<div class="text-muted-foreground">
|
||||
订单号
|
||||
</div>
|
||||
<div class="font-mono">
|
||||
{{ currentOrder.orderNo }}
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-2">
|
||||
金额
|
||||
</div>
|
||||
<div class="font-mono font-bold text-lg">
|
||||
¥{{ formatAmount(currentOrder.amount) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="approveStatus === 3" class="grid gap-2">
|
||||
<UiLabel>驳回原因 <span class="text-red-500">*</span></UiLabel>
|
||||
<UiInput v-model="rejectReason" placeholder="请输入驳回原因" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<UiLabel>管理员备注</UiLabel>
|
||||
<UiInput v-model="adminRemark" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="showApproveDialog = false">
|
||||
取消
|
||||
</UiButton>
|
||||
<UiButton
|
||||
:variant="approveStatus === 3 ? 'destructive' : 'default'"
|
||||
:disabled="approveMutation.isPending.value"
|
||||
@click="handleApprove"
|
||||
>
|
||||
<UiSpinner v-if="approveMutation.isPending.value" class="mr-2" />
|
||||
确认
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
370
monisuo-admin/src/pages/monisuo/users.vue
Normal file
370
monisuo-admin/src/pages/monisuo/users.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<script lang="ts" setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import type { User } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
import { useGetUserListQuery, useUpdateUserStatusMutation } from '@/services/api/monisuo-admin.api'
|
||||
|
||||
const pageNum = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const searchUsername = ref('')
|
||||
const searchStatus = ref<number | undefined>()
|
||||
const searchStatusTemp = ref<number | string>('all')
|
||||
|
||||
const { data, isLoading, refetch } = useGetUserListQuery({
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
username: searchUsername.value || undefined,
|
||||
status: searchStatus.value,
|
||||
})
|
||||
|
||||
const updateStatusMutation = useUpdateUserStatusMutation()
|
||||
|
||||
const users = computed(() => data.value?.data?.list || [])
|
||||
const total = computed(() => data.value?.data?.total || 0)
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
// 用户详情弹窗
|
||||
const showDetailDialog = ref(false)
|
||||
const selectedUser = ref<User | null>(null)
|
||||
|
||||
function viewUserDetail(user: User) {
|
||||
selectedUser.value = user
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
async function toggleStatus(user: User) {
|
||||
const newStatus = user.status === 1 ? 0 : 1
|
||||
const action = newStatus === 0 ? '禁用' : '启用'
|
||||
|
||||
try {
|
||||
await updateStatusMutation.mutateAsync({ userId: user.id, status: newStatus })
|
||||
toast.success(`已${action}用户 ${user.username}`)
|
||||
}
|
||||
catch (e: any) {
|
||||
toast.error(e.response?.data?.msg || `${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
searchStatus.value = searchStatusTemp.value === 'all' ? undefined : searchStatusTemp.value as number
|
||||
pageNum.value = 1
|
||||
refetch()
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
searchUsername.value = ''
|
||||
searchStatusTemp.value = 'all'
|
||||
searchStatus.value = undefined
|
||||
pageNum.value = 1
|
||||
refetch()
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pageNum.value = page
|
||||
refetch()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(size: unknown) {
|
||||
if (size === null || size === undefined)
|
||||
return
|
||||
pageSize.value = Number(size)
|
||||
pageNum.value = 1
|
||||
refetch()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage title="用户管理" description="管理系统用户">
|
||||
<div class="space-y-4">
|
||||
<!-- 搜索栏 -->
|
||||
<UiCard class="p-4">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1 grid gap-2">
|
||||
<UiLabel>用户名</UiLabel>
|
||||
<UiInput v-model="searchUsername" placeholder="搜索用户名" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="w-full sm:w-[160px] grid gap-2">
|
||||
<UiLabel>状态</UiLabel>
|
||||
<UiSelect v-model="searchStatusTemp">
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="全部" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="all">
|
||||
全部
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="1">
|
||||
正常
|
||||
</UiSelectItem>
|
||||
<UiSelectItem :value="0">
|
||||
禁用
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<UiButton @click="search">
|
||||
搜索
|
||||
</UiButton>
|
||||
<UiButton variant="outline" @click="resetSearch">
|
||||
重置
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<!-- PC端表格 -->
|
||||
<UiCard class="hidden md:block overflow-x-auto p-4">
|
||||
<UiTable>
|
||||
<UiTableHeader>
|
||||
<UiTableRow>
|
||||
<UiTableHead class="w-[80px]">
|
||||
ID
|
||||
</UiTableHead>
|
||||
<UiTableHead>用户名</UiTableHead>
|
||||
<UiTableHead>昵称</UiTableHead>
|
||||
<UiTableHead class="hidden lg:table-cell">
|
||||
手机
|
||||
</UiTableHead>
|
||||
<UiTableHead class="hidden xl:table-cell">
|
||||
邮箱
|
||||
</UiTableHead>
|
||||
<UiTableHead>状态</UiTableHead>
|
||||
<UiTableHead class="hidden sm:table-cell">
|
||||
注册时间
|
||||
</UiTableHead>
|
||||
<UiTableHead class="text-right">
|
||||
操作
|
||||
</UiTableHead>
|
||||
</UiTableRow>
|
||||
</UiTableHeader>
|
||||
<UiTableBody>
|
||||
<UiTableRow v-if="isLoading">
|
||||
<UiTableCell :col-span="8" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-else-if="users.length === 0">
|
||||
<UiTableCell :col-span="8" class="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
<UiTableRow v-for="user in users" :key="user.id">
|
||||
<UiTableCell>{{ user.id }}</UiTableCell>
|
||||
<UiTableCell class="font-medium">
|
||||
{{ user.username }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>{{ user.nickname || '-' }}</UiTableCell>
|
||||
<UiTableCell class="hidden lg:table-cell">
|
||||
{{ user.phone || '-' }}
|
||||
</UiTableCell>
|
||||
<UiTableCell class="hidden xl:table-cell">
|
||||
{{ user.email || '-' }}
|
||||
</UiTableCell>
|
||||
<UiTableCell>
|
||||
<UiBadge :variant="user.status === 1 ? 'default' : 'destructive'">
|
||||
{{ user.status === 1 ? '正常' : '禁用' }}
|
||||
</UiBadge>
|
||||
</UiTableCell>
|
||||
<UiTableCell class="hidden sm:table-cell text-muted-foreground text-sm">
|
||||
{{ user.createTime }}
|
||||
</UiTableCell>
|
||||
<UiTableCell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<UiButton size="sm" variant="ghost" @click="viewUserDetail(user)">
|
||||
<Icon icon="lucide:eye" class="size-4" />
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="updateStatusMutation.isPending.value"
|
||||
@click="toggleStatus(user)"
|
||||
>
|
||||
{{ user.status === 1 ? '禁用' : '启用' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiTableCell>
|
||||
</UiTableRow>
|
||||
</UiTableBody>
|
||||
</UiTable>
|
||||
</UiCard>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<div v-if="isLoading" class="text-center py-8">
|
||||
<UiSpinner class="mx-auto" />
|
||||
</div>
|
||||
<template v-else-if="users.length > 0">
|
||||
<UiCard v-for="user in users" :key="user.id" class="p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{{ user.nickname || '未设置昵称' }}
|
||||
</div>
|
||||
</div>
|
||||
<UiBadge :variant="user.status === 1 ? 'default' : 'destructive'">
|
||||
{{ user.status === 1 ? '正常' : '禁用' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t text-sm text-muted-foreground space-y-1">
|
||||
<div v-if="user.phone">
|
||||
手机: {{ user.phone }}
|
||||
</div>
|
||||
<div>注册: {{ user.createTime }}</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<UiButton size="sm" variant="outline" class="flex-1" @click="viewUserDetail(user)">
|
||||
查看详情
|
||||
</UiButton>
|
||||
<UiButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="updateStatusMutation.isPending.value"
|
||||
@click="toggleStatus(user)"
|
||||
>
|
||||
{{ user.status === 1 ? '禁用' : '启用' }}
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</template>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
暂无数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="total > 0" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
共 {{ total }} 条记录
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">每页</span>
|
||||
<UiSelect :model-value="`${pageSize}`" @update:model-value="handlePageSizeChange">
|
||||
<UiSelectTrigger class="h-8 w-[70px]">
|
||||
<UiSelectValue />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectItem value="10">
|
||||
10
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="20">
|
||||
20
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="50">
|
||||
50
|
||||
</UiSelectItem>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<span class="text-sm">条</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="pageNum <= 1"
|
||||
@click="handlePageChange(pageNum - 1)"
|
||||
>
|
||||
<Icon icon="lucide:chevron-left" class="size-4" />
|
||||
</UiButton>
|
||||
<span class="text-sm min-w-[80px] text-center">
|
||||
{{ pageNum }} / {{ totalPages }}
|
||||
</span>
|
||||
<UiButton
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="pageNum >= totalPages"
|
||||
@click="handlePageChange(pageNum + 1)"
|
||||
>
|
||||
<Icon icon="lucide:chevron-right" class="size-4" />
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户详情弹窗 -->
|
||||
<UiDialog v-model:open="showDetailDialog">
|
||||
<UiDialogContent class="max-w-md">
|
||||
<UiDialogHeader>
|
||||
<UiDialogTitle>用户详情</UiDialogTitle>
|
||||
</UiDialogHeader>
|
||||
<div v-if="selectedUser" class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||
<div class="text-muted-foreground">
|
||||
用户ID
|
||||
</div>
|
||||
<div class="col-span-2 font-medium">
|
||||
{{ selectedUser.id }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
用户名
|
||||
</div>
|
||||
<div class="col-span-2 font-medium">
|
||||
{{ selectedUser.username }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
昵称
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ selectedUser.nickname || '-' }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
手机
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ selectedUser.phone || '-' }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
邮箱
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ selectedUser.email || '-' }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
状态
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<UiBadge :variant="selectedUser.status === 1 ? 'default' : 'destructive'">
|
||||
{{ selectedUser.status === 1 ? '正常' : '禁用' }}
|
||||
</UiBadge>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
注册时间
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ selectedUser.createTime }}
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground">
|
||||
更新时间
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{{ selectedUser.updateTime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiDialogFooter>
|
||||
<UiButton variant="outline" @click="showDetailDialog = false">
|
||||
关闭
|
||||
</UiButton>
|
||||
</UiDialogFooter>
|
||||
</UiDialogContent>
|
||||
</UiDialog>
|
||||
</BasicPage>
|
||||
</template>
|
||||
10
monisuo-admin/src/pages/settings/account.vue
Normal file
10
monisuo-admin/src/pages/settings/account.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import AccountForm from './components/account-form.vue'
|
||||
import SettingsLayout from './components/settings-layout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout>
|
||||
<AccountForm />
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
10
monisuo-admin/src/pages/settings/appearance.vue
Normal file
10
monisuo-admin/src/pages/settings/appearance.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import AppearanceForm from './components/appearance-form.vue'
|
||||
import SettingsLayout from './components/settings-layout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout>
|
||||
<AppearanceForm />
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
181
monisuo-admin/src/pages/settings/components/account-form.vue
Normal file
181
monisuo-admin/src/pages/settings/components/account-form.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarDate, DateFormatter, getLocalTimeZone, today } from '@internationalized/date'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { CalendarDays, Check, ChevronsUpDown } from 'lucide-vue-next'
|
||||
import { toDate } from 'reka-ui/date'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { accountValidator } from '../validators/account.validator'
|
||||
|
||||
const open = ref(false)
|
||||
const dateValue = ref()
|
||||
const placeholder = ref()
|
||||
|
||||
const languages = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'French', value: 'fr' },
|
||||
{ label: 'German', value: 'de' },
|
||||
{ label: 'Spanish', value: 'es' },
|
||||
{ label: 'Portuguese', value: 'pt' },
|
||||
{ label: 'Russian', value: 'ru' },
|
||||
{ label: 'Japanese', value: 'ja' },
|
||||
{ label: 'Korean', value: 'ko' },
|
||||
{ label: 'Chinese', value: 'zh' },
|
||||
] as const
|
||||
|
||||
const df = new DateFormatter('en-US', {
|
||||
dateStyle: 'long',
|
||||
})
|
||||
|
||||
const accountFormSchema = toTypedSchema(accountValidator)
|
||||
|
||||
// https://github.com/logaretm/vee-validate/issues/3521
|
||||
// https://github.com/logaretm/vee-validate/discussions/3571
|
||||
async function onSubmit(values: any) {
|
||||
toast('You submitted the following values:', {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">
|
||||
Account
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Update your account settings. Set your preferred language and timezone.
|
||||
</p>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<Form v-slot="{ setFieldValue }" :validation-schema="accountFormSchema" class="space-y-8" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="name">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="Your name" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is the name that will be displayed on your profile and in emails.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ field, value }" name="dob">
|
||||
<FormItem class="flex flex-col">
|
||||
<FormLabel>Date of birth</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline" :class="cn(
|
||||
'w-[240px] justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
)"
|
||||
>
|
||||
<CalendarDays class="size-4 opacity-50" />
|
||||
<span>{{ value ? df.format(toDate(dateValue, getLocalTimeZone())) : "Pick a date" }}</span>
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<Calendar
|
||||
v-model:placeholder="placeholder"
|
||||
v-model="dateValue"
|
||||
calendar-label="Date of birth"
|
||||
initial-focus
|
||||
:min-value="new CalendarDate(1900, 1, 1)"
|
||||
:max-value="today(getLocalTimeZone())"
|
||||
@update:model-value="(v) => {
|
||||
if (v) {
|
||||
dateValue = v
|
||||
setFieldValue('dob', toDate(v).toISOString())
|
||||
}
|
||||
else {
|
||||
dateValue = undefined
|
||||
setFieldValue('dob', undefined)
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Your date of birth is used to calculate your age.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<input type="hidden" v-bind="field">
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value }" name="language">
|
||||
<FormItem class="flex flex-col">
|
||||
<FormLabel>Language</FormLabel>
|
||||
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline" role="combobox" :aria-expanded="open" :class="cn(
|
||||
'w-[200px] justify-between',
|
||||
!value && 'text-muted-foreground',
|
||||
)"
|
||||
>
|
||||
{{ value ? languages.find(
|
||||
(language) => language.value === value,
|
||||
)?.label : 'Select language...' }}
|
||||
|
||||
<ChevronsUpDown class="size-4 ml-2 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search language..." />
|
||||
<CommandEmpty>No language found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="language in languages" :key="language.value" :value="language.label"
|
||||
@select="() => {
|
||||
setFieldValue('language', language.value)
|
||||
open = false
|
||||
}"
|
||||
>
|
||||
<Check
|
||||
:class="cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === language.value ? 'opacity-100' : 'opacity-0',
|
||||
)"
|
||||
/>
|
||||
{{ language.label }}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<FormDescription>
|
||||
This is the language that will be used in the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<Button type="submit">
|
||||
Update account
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</template>
|
||||
149
monisuo-admin/src/pages/settings/components/appearance-form.vue
Normal file
149
monisuo-admin/src/pages/settings/components/appearance-form.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
import { appearanceValidator } from '../validators/appearance.validator'
|
||||
|
||||
const KEY = 'appearance_config'
|
||||
const DESCRIPTION = 'Customize the appearance of the app. Automatically switch between day and night themes.'
|
||||
const DEFAULT_APPEARANCE_CONFIG_VALUE = {
|
||||
theme: 'light',
|
||||
font: 'inter',
|
||||
} as const
|
||||
|
||||
const { isGetting, isPending, onSubmit } = useSystemConfig({
|
||||
key: KEY,
|
||||
description: DESCRIPTION,
|
||||
defaultValue: DEFAULT_APPEARANCE_CONFIG_VALUE,
|
||||
schema: appearanceValidator,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">
|
||||
Appearance
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Customize the appearance of the app. Automatically switch between day and night themes.
|
||||
</p>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
|
||||
<div v-if="isGetting">
|
||||
<Button variant="secondary" disabled size="sm">
|
||||
<Spinner />
|
||||
Please wait
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form v-if="!isGetting" class="space-y-8" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="font">
|
||||
<FormItem>
|
||||
<FormLabel>Font</FormLabel>
|
||||
<UiSelect v-bind="componentField">
|
||||
<UiFormControl>
|
||||
<UiSelectTrigger>
|
||||
<UiSelectValue placeholder="Select a font" />
|
||||
</UiSelectTrigger>
|
||||
</UiFormControl>
|
||||
<UiSelectContent>
|
||||
<UiSelectGroup>
|
||||
<UiSelectItem value="inter">
|
||||
Inter
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="manrope">
|
||||
Manrope
|
||||
</UiSelectItem>
|
||||
<UiSelectItem value="system">
|
||||
System
|
||||
</UiSelectItem>
|
||||
</UiSelectGroup>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
<FormDescription>
|
||||
Set the font you want to use in the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" type="radio" name="theme">
|
||||
<FormItem class="space-y-1">
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<FormDescription>
|
||||
Select the theme for the dashboard.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
|
||||
<RadioGroup
|
||||
class="grid max-w-md grid-cols-2 gap-8 pt-2"
|
||||
v-bind="componentField"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel class="[&:has([data-state=checked])>div]:border-primary flex flex-col">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="light" class="sr-only" />
|
||||
</FormControl>
|
||||
<div class="items-center p-1 border-2 rounded-md border-muted hover:border-accent">
|
||||
<div class="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||
<div class="p-2 space-y-2 bg-white rounded-md shadow-xs">
|
||||
<div class="h-2 w-20 rounded-lg bg-[#ecedef]" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div class="flex items-center p-2 space-x-2 bg-white rounded-md shadow-xs">
|
||||
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div class="flex items-center p-2 space-x-2 bg-white rounded-md shadow-xs">
|
||||
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 font-normal text-center">
|
||||
Light
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel class="[&:has([data-state=checked])>div]:border-primary flex flex-col">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="dark" class="sr-only" />
|
||||
</FormControl>
|
||||
<div class="items-center p-1 border-2 rounded-md border-muted bg-popover hover:bg-accent hover:text-accent-foreground">
|
||||
<div class="p-2 space-y-2 rounded-sm bg-slate-950">
|
||||
<div class="p-2 space-y-2 rounded-md shadow-xs bg-slate-800">
|
||||
<div class="w-20 h-2 rounded-lg bg-slate-400" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div class="flex items-center p-2 space-x-2 rounded-md shadow-xs bg-slate-800">
|
||||
<div class="size-4 rounded-full bg-slate-400" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div class="flex items-center p-2 space-x-2 rounded-md shadow-xs bg-slate-800">
|
||||
<div class="size-4 rounded-full bg-slate-400" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 font-normal text-center">
|
||||
Dark
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<Button type="submit" :disabled="isPending">
|
||||
<Spinner v-if="isPending" size="sm" />
|
||||
Update preferences
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
105
monisuo-admin/src/pages/settings/components/display-form.vue
Normal file
105
monisuo-admin/src/pages/settings/components/display-form.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
import { displayValidator } from '../validators/display.validator'
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 'recents',
|
||||
label: 'Recents',
|
||||
},
|
||||
{
|
||||
id: 'home',
|
||||
label: 'Home',
|
||||
},
|
||||
{
|
||||
id: 'applications',
|
||||
label: 'Applications',
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
label: 'Desktop',
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: 'Downloads',
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
label: 'Documents',
|
||||
},
|
||||
] as const
|
||||
|
||||
const displayFormSchema = toTypedSchema(displayValidator)
|
||||
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: displayFormSchema,
|
||||
initialValues: {
|
||||
items: ['recents', 'home'],
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast('You submitted the following values:', {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">
|
||||
Display
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Turn items on or off to control what's displayed in the app.
|
||||
</p>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<form @submit="onSubmit">
|
||||
<FormField name="items">
|
||||
<FormItem>
|
||||
<div class="mb-4">
|
||||
<FormLabel class="text-base">
|
||||
Sidebar
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Select the items you want to display in the sidebar.
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<FormField v-for="item in items" v-slot="{ value, handleChange }" :key="item.id" name="items">
|
||||
<FormItem :key="item.id" class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
:model-value="value.includes(item.id)"
|
||||
@update:model-value="(checked: boolean | 'indeterminate') => {
|
||||
if (Array.isArray(value)) {
|
||||
handleChange(checked ? [...value, item.id] : value.filter(id => id !== item.id))
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel class="font-normal">
|
||||
{{ item.label }}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="flex justify-start mt-4">
|
||||
<Button type="submit">
|
||||
Update display
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
import { notificationsValidator } from '../validators/notifications.validator'
|
||||
|
||||
const notificationsFormSchema = toTypedSchema(notificationsValidator)
|
||||
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: notificationsFormSchema,
|
||||
initialValues: {
|
||||
communication_emails: false,
|
||||
marketing_emails: false,
|
||||
social_emails: true,
|
||||
security_emails: true,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast('You submitted the following values:', {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">
|
||||
Notifications
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Configure how you receive notifications.
|
||||
</p>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<form class="space-y-8" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" type="radio" name="type">
|
||||
<FormItem class="space-y-3">
|
||||
<FormLabel>Notify me about...</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
class="flex flex-col space-y-1"
|
||||
v-bind="componentField"
|
||||
>
|
||||
<FormItem class="flex items-center space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="all" />
|
||||
</FormControl>
|
||||
<FormLabel class="font-normal">
|
||||
All new messages
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem class="flex items-center space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="mentions" />
|
||||
</FormControl>
|
||||
<FormLabel class="font-normal">
|
||||
Direct messages and mentions
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem class="flex items-center space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="none" />
|
||||
</FormControl>
|
||||
<FormLabel class="font-normal">
|
||||
Nothing
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-medium">
|
||||
Email Notifications
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ handleChange, value }" type="checkbox" name="communication_emails">
|
||||
<FormItem class="flex flex-row items-center justify-between p-4 border rounded-lg">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">
|
||||
Communication emails
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails about your account activity.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:checked="value"
|
||||
@update:checked="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ handleChange, value }" type="checkbox" name="marketing_emails">
|
||||
<FormItem class="flex flex-row items-center justify-between p-4 border rounded-lg">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">
|
||||
Marketing emails
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails about new products, features, and more.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:checked="value"
|
||||
@update:checked="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ handleChange, value }" type="checkbox" name="social_emails">
|
||||
<FormItem class="flex flex-row items-center justify-between p-4 border rounded-lg">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">
|
||||
Social emails
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails for friend requests, follows, and more.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:checked="value"
|
||||
@update:checked="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ handleChange, value }" type="checkbox" name="security_emails">
|
||||
<FormItem class="flex flex-row items-center justify-between p-4 border rounded-lg">
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base">
|
||||
Security emails
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Receive emails about your account activity and security.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:checked="value"
|
||||
@update:checked="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField v-slot="{ handleChange, value }" type="checkbox" name="mobile">
|
||||
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<div class="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
Use different settings for my mobile devices
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
You can manage your mobile notifications in the
|
||||
<a href="/examples/forms">
|
||||
mobile settings
|
||||
</a> page.
|
||||
</FormDescription>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div class="flex justify-start">
|
||||
<Button type="submit">
|
||||
Update notifications
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
158
monisuo-admin/src/pages/settings/components/profile-form.vue
Normal file
158
monisuo-admin/src/pages/settings/components/profile-form.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { FieldArray, useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { profileValidator } from '../validators/profile.validator'
|
||||
|
||||
const verifiedEmails = ref(['m@example.com', 'm@google.com', 'm@support.com'])
|
||||
|
||||
const profileFormSchema = toTypedSchema(profileValidator)
|
||||
|
||||
const { handleSubmit, resetForm } = useForm({
|
||||
validationSchema: profileFormSchema,
|
||||
initialValues: {
|
||||
bio: 'I own a computer.',
|
||||
urls: [
|
||||
{ value: 'https://shadcn.com' },
|
||||
{ value: 'http://twitter.com/shadcn' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast('You submitted the following values:', {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">
|
||||
Profile
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
This is how others will see you on the site.
|
||||
</p>
|
||||
</div>
|
||||
<Separator orientation="horizontal" class="my-4" />
|
||||
<form class="space-y-8" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="shadcn" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This is your public display name. It can be your real name or a pseudonym. You can only change this once every 30 days.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
|
||||
<Select v-bind="componentField">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an email" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="email in verifiedEmails" :key="email" :value="email">
|
||||
{{ email }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
You can manage verified email addresses in your email settings.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="bio">
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Tell us a little bit about yourself" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
You can <span>@mention</span> other users and organizations to link to them.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<div>
|
||||
<FieldArray v-slot="{ fields, push, remove }" name="urls">
|
||||
<div v-for="(field, index) in fields" :key="`urls-${field.key}`" class="mb-2">
|
||||
<FormField v-slot="{ componentField }" :name="`urls[${index}].value`">
|
||||
<FormItem>
|
||||
<FormLabel :class="cn(index !== 0 && 'sr-only')">
|
||||
URLs
|
||||
</FormLabel>
|
||||
<FormDescription :class="cn(index !== 0 && 'sr-only')">
|
||||
Add links to your website, blog, or social media profiles.
|
||||
</FormDescription>
|
||||
<div class="relative flex items-center">
|
||||
<FormControl>
|
||||
<Input type="url" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<button type="button" class="absolute py-2 pe-3 end-0 text-muted-foreground" @click="remove(index)">
|
||||
<X class="w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-20 mt-2 text-xs"
|
||||
@click="push({ value: '' })"
|
||||
>
|
||||
Add URL
|
||||
</Button>
|
||||
</FieldArray>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-start gap-2">
|
||||
<Button type="submit">
|
||||
Update profile
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@click="resetForm"
|
||||
>
|
||||
Reset form
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronsUpDownIcon } from 'lucide-vue-next'
|
||||
|
||||
import { useSidebar } from '@/composables/use-sidebar'
|
||||
|
||||
const route = useRoute()
|
||||
const currentPath = computed(() => route.path)
|
||||
const activeClass = 'text-primary font-semibold bg-primary/5'
|
||||
|
||||
const { settingsNavItems } = useSidebar()
|
||||
const currentLink = computed(() => settingsNavItems.find(link => link.url === currentPath.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="flex flex-col gap-2">
|
||||
<router-link
|
||||
v-for="link in settingsNavItems" :key="link.url"
|
||||
:to="link.url"
|
||||
class="items-center hidden px-2 py-1 rounded-md lg:flex hover:bg-primary/5"
|
||||
:class="link.url === currentPath ? activeClass : ''"
|
||||
>
|
||||
<component :is="link.icon" class="size-4 mr-1" />
|
||||
<span>{{ link.title }}</span>
|
||||
</router-link>
|
||||
|
||||
<UiDropdownMenu class="lg:hidden">
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton variant="outline" class="w-48 lg:hidden">
|
||||
<component :is="currentLink?.icon" class="size-4 mr-1" />
|
||||
<span>{{ currentLink?.title }}</span>
|
||||
<ChevronsUpDownIcon class="size-4 ml-auto" />
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent class="w-48" align="start">
|
||||
<UiDropdownMenuItem
|
||||
v-for="link in settingsNavItems" :key="link.url"
|
||||
@click="$router.push(link.url)"
|
||||
>
|
||||
<component :is="link.icon" class="size-4 mr-1" />
|
||||
{{ link.title }}
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { BasicPage, TwoColAside, TwoColLayout } from '@/components/global-layout'
|
||||
|
||||
const { settingsNavItems } = useSidebar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="Settings"
|
||||
description="Manage your store settings."
|
||||
>
|
||||
<TwoColLayout>
|
||||
<template #aside>
|
||||
<TwoColAside :nav="settingsNavItems" />
|
||||
</template>
|
||||
|
||||
<slot />
|
||||
</TwoColLayout>
|
||||
</BasicPage>
|
||||
</template>
|
||||
10
monisuo-admin/src/pages/settings/display.vue
Normal file
10
monisuo-admin/src/pages/settings/display.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import DisplayForm from './components/display-form.vue'
|
||||
import SettingsLayout from './components/settings-layout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout>
|
||||
<DisplayForm />
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
10
monisuo-admin/src/pages/settings/index.vue
Normal file
10
monisuo-admin/src/pages/settings/index.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import ProfileForm from './components/profile-form.vue'
|
||||
import SettingsLayout from './components/settings-layout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout>
|
||||
<ProfileForm />
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
10
monisuo-admin/src/pages/settings/notifications.vue
Normal file
10
monisuo-admin/src/pages/settings/notifications.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import NotificationsForm from './components/notifications-form.vue'
|
||||
import SettingsLayout from './components/settings-layout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout>
|
||||
<NotificationsForm />
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const accountValidator = z.object({
|
||||
name: z
|
||||
.string({
|
||||
error: 'Required.',
|
||||
})
|
||||
.min(2, {
|
||||
error: 'Name must be at least 2 characters.',
|
||||
})
|
||||
.max(30, {
|
||||
error: 'Name must not be longer than 30 characters.',
|
||||
}),
|
||||
dob: z
|
||||
.iso
|
||||
.datetime()
|
||||
.optional()
|
||||
.refine(date => date !== undefined, 'Please select a valid date.'),
|
||||
language: z
|
||||
.string()
|
||||
.min(1, 'Please select a language.'),
|
||||
})
|
||||
|
||||
export type AccountValidator = z.infer<typeof accountValidator>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const appearanceValidator = z.object({
|
||||
theme: z
|
||||
.enum(['light', 'dark'], {
|
||||
error: 'Please select a theme.',
|
||||
}),
|
||||
font: z
|
||||
.enum(['inter', 'manrope', 'system'], {
|
||||
error: 'Please select a font.',
|
||||
}),
|
||||
})
|
||||
|
||||
export type AppearanceValidator = z.infer<typeof appearanceValidator>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const displayValidator = z.object({
|
||||
items: z
|
||||
.array(z.string())
|
||||
.refine(value => value.some(item => item), {
|
||||
error: 'You have to select at least one item.',
|
||||
}),
|
||||
})
|
||||
|
||||
export type DisplayValidator = z.infer<typeof displayValidator>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const notificationsValidator = z.object({
|
||||
type: z
|
||||
.enum(['all', 'mentions', 'none'], {
|
||||
error: 'You need to select a notification type.',
|
||||
}),
|
||||
mobile: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional(),
|
||||
communication_emails: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional(),
|
||||
social_emails: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional(),
|
||||
marketing_emails: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.optional(),
|
||||
security_emails: z
|
||||
.boolean(),
|
||||
})
|
||||
|
||||
export type NotificationsValidator = z.infer<typeof notificationsValidator>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const profileValidator = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(2, {
|
||||
error: 'Username must be at least 2 characters.',
|
||||
})
|
||||
.max(30, {
|
||||
error: 'Username must not be longer than 30 characters.',
|
||||
}),
|
||||
email: z
|
||||
.email({
|
||||
error: 'Please select an email to display.',
|
||||
}),
|
||||
bio: z
|
||||
.string()
|
||||
.max(160, { error: 'Bio must not be longer than 160 characters.' })
|
||||
.min(4, { error: 'Bio must be at least 2 characters.' }),
|
||||
urls: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.url({ error: 'Please enter a valid URL.' }),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type ProfileValidator = z.infer<typeof profileValidator>
|
||||
80
monisuo-admin/src/pages/tasks/components/columns.ts
Normal file
80
monisuo-admin/src/pages/tasks/components/columns.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { h } from 'vue'
|
||||
|
||||
import { DataTableColumnHeader, SelectColumn } from '@/components/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
import { labels, priorities, statuses } from '../data/data'
|
||||
import DataTableRowActions from './data-table-row-actions.vue'
|
||||
|
||||
export const columns: ColumnDef<Task>[] = [
|
||||
SelectColumn as ColumnDef<Task>,
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => h(DataTableColumnHeader<Task>, { column, title: 'Task' }),
|
||||
cell: ({ row }) => h('div', { class: 'w-20' }, row.getValue('id')),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: ({ column }) => h(DataTableColumnHeader<Task>, { column, title: 'Title' }),
|
||||
|
||||
cell: ({ row }) => {
|
||||
const label = labels.find(label => label.value === row.original.label)
|
||||
|
||||
return h('div', { class: 'flex space-x-2' }, [
|
||||
label ? h(Badge, { variant: 'outline' }, () => label.label) : null,
|
||||
h('span', { class: 'max-w-[500px] truncate font-medium' }, row.getValue('title')),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => h(DataTableColumnHeader<Task>, { column, title: 'Status' }),
|
||||
|
||||
cell: ({ row }) => {
|
||||
const status = statuses.find(
|
||||
status => status.value === row.getValue('status'),
|
||||
)
|
||||
|
||||
if (!status)
|
||||
return null
|
||||
|
||||
return h('div', { class: 'flex w-[100px] items-center' }, [
|
||||
status.icon && h(status.icon, { class: 'mr-2 h-4 w-4 text-muted-foreground' }),
|
||||
h('span', status.label),
|
||||
])
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => h(DataTableColumnHeader<Task>, { column, title: 'Priority' }),
|
||||
cell: ({ row }) => {
|
||||
const priority = priorities.find(
|
||||
priority => priority.value === row.getValue('priority'),
|
||||
)
|
||||
|
||||
if (!priority)
|
||||
return null
|
||||
|
||||
return h('div', { class: 'flex items-center' }, [
|
||||
priority.icon && h(priority.icon, { class: 'mr-2 h-4 w-4 text-muted-foreground' }),
|
||||
h('span', {}, priority.label),
|
||||
])
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => h(DataTableRowActions, { row }),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import type { Row } from '@tanstack/vue-table'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Ellipsis, FilePenLine, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
import { Modal, ModalContent } from '@/components/prop-ui/modal'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
import { labels } from '../data/data'
|
||||
import { taskSchema } from '../data/schema'
|
||||
|
||||
const props = defineProps<DataTableRowActionsProps>()
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<Task>
|
||||
}
|
||||
const task = computed(() => taskSchema.parse(props.row.original))
|
||||
|
||||
const taskLabel = ref(task.value.label)
|
||||
|
||||
const showComponent = shallowRef<Component | null>(null)
|
||||
const isOpen = ref(false)
|
||||
|
||||
type TCommand = 'edit' | 'create' | 'delete'
|
||||
|
||||
const componentLoader: Record<TCommand, () => Promise<{ default: Component }>> = {
|
||||
edit: () => import('./task-resource-dialog.vue'),
|
||||
create: () => import('./task-resource-dialog.vue'),
|
||||
delete: () => import('./task-delete.vue'),
|
||||
}
|
||||
|
||||
async function handleSelect(command: TCommand) {
|
||||
try {
|
||||
const { default: component } = await componentLoader[command]()
|
||||
showComponent.value = component
|
||||
isOpen.value = true
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Failed to load component for "${command}"`, e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="isOpen">
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">Open menu</span>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="end" class="w-[160px]">
|
||||
<UiDropdownMenuItem @select.stop="handleSelect('edit')">
|
||||
<span>Edit</span>
|
||||
<UiDropdownMenuShortcut> <FilePenLine class="size-4" /> </UiDropdownMenuShortcut>
|
||||
</UiDropdownMenuItem>
|
||||
|
||||
<UiDropdownMenuItem disabled>
|
||||
Make a copy
|
||||
</UiDropdownMenuItem>
|
||||
<UiDropdownMenuItem disabled>
|
||||
Favorite
|
||||
</UiDropdownMenuItem>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
|
||||
<UiDropdownMenuSub>
|
||||
<UiDropdownMenuSubTrigger>Labels</UiDropdownMenuSubTrigger>
|
||||
<UiDropdownMenuSubContent>
|
||||
<UiDropdownMenuRadioGroup v-model="taskLabel">
|
||||
<UiDropdownMenuRadioItem v-for="label in labels" :key="label.value" :value="label.value">
|
||||
{{ label.label }}
|
||||
</UiDropdownMenuRadioItem>
|
||||
</UiDropdownMenuRadioGroup>
|
||||
</UiDropdownMenuSubContent>
|
||||
</UiDropdownMenuSub>
|
||||
|
||||
<UiDropdownMenuSeparator />
|
||||
|
||||
<UiDropdownMenuItem @select.stop="handleSelect('delete')">
|
||||
<span>Delete</span>
|
||||
<UiDropdownMenuShortcut> <Trash2 class="size-4" /> </UiDropdownMenuShortcut>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
|
||||
<ModalContent>
|
||||
<component :is="showComponent" :task="task" @close="isOpen = false" />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
import { DataTableFacetedFilter, DataTableViewOptions } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
import { priorities, statuses } from '../data/data'
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<Task>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col items-start flex-1 space-y-2 md:items-center md:space-x-2 md:space-y-0 md:flex-row">
|
||||
<Input
|
||||
placeholder="Filter tasks..."
|
||||
:model-value="(table.getColumn('title')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('title')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
|
||||
<div class="space-x-2">
|
||||
<DataTableFacetedFilter
|
||||
v-if="table.getColumn('status')"
|
||||
:column="table.getColumn('status')"
|
||||
title="Status"
|
||||
:options="statuses"
|
||||
/>
|
||||
<DataTableFacetedFilter
|
||||
v-if="table.getColumn('priority')"
|
||||
:column="table.getColumn('priority')"
|
||||
title="Priority"
|
||||
:options="priorities"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
Reset
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" />
|
||||
</div>
|
||||
</template>
|
||||
51
monisuo-admin/src/pages/tasks/components/data-table.vue
Normal file
51
monisuo-admin/src/pages/tasks/components/data-table.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2Icon } from 'lucide-vue-next'
|
||||
|
||||
import type { DataTableProps } from '@/components/data-table'
|
||||
|
||||
import { DataTable, DataTableBulkActions, useGenerateVueTable } from '@/components/data-table'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
import DataTableToolbar from './data-table-toolbar.vue'
|
||||
import TaskDeleteBatch from './task-delete-batch.vue'
|
||||
|
||||
const props = defineProps<DataTableProps<Task>>()
|
||||
const table = useGenerateVueTable<Task>(props)
|
||||
|
||||
const taskDeleteBatchOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTableBulkActions entity-name="task" :table="table">
|
||||
<UiTooltip>
|
||||
<UiTooltipTrigger as-child>
|
||||
<UiButton
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
class="size-8"
|
||||
aria-label="Delete selected tasks"
|
||||
title="Delete selected tasks"
|
||||
@click="taskDeleteBatchOpen = true"
|
||||
>
|
||||
<Trash2Icon />
|
||||
<span class="sr-only">Delete selected tasks</span>
|
||||
</UiButton>
|
||||
</UiTooltipTrigger>
|
||||
<UiTooltipContent>
|
||||
<p>Delete selected tasks</p>
|
||||
</UiTooltipContent>
|
||||
</UiTooltip>
|
||||
|
||||
<TaskDeleteBatch
|
||||
v-model:open="taskDeleteBatchOpen"
|
||||
:table
|
||||
/>
|
||||
</DataTableBulkActions>
|
||||
|
||||
<DataTable :columns :table :data :loading>
|
||||
<template #toolbar>
|
||||
<DataTableToolbar :table="table" class="w-full overflow-x-auto" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
24
monisuo-admin/src/pages/tasks/components/task-create.vue
Normal file
24
monisuo-admin/src/pages/tasks/components/task-create.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
|
||||
import { Modal, ModalContent, ModalTrigger } from '@/components/prop-ui/modal'
|
||||
|
||||
import TaskResourceDialog from './task-resource-dialog.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="isOpen">
|
||||
<ModalTrigger as-child>
|
||||
<UiButton>
|
||||
Create
|
||||
<Plus />
|
||||
</UiButton>
|
||||
</ModalTrigger>
|
||||
|
||||
<ModalContent>
|
||||
<TaskResourceDialog :task="null" @close="isOpen = false" />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup generic="T = Task">
|
||||
import type { Table as VueTable } from '@tanstack/vue-table'
|
||||
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import ConfirmDialog from '@/components/confirm-dialog.vue'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
const { table } = defineProps<{
|
||||
table: VueTable<T>
|
||||
}>()
|
||||
|
||||
const openModel = defineModel<boolean>('open', {
|
||||
default: false,
|
||||
})
|
||||
|
||||
const CONFIRM_WORD = 'DELETE'
|
||||
|
||||
const confirmValue = ref('')
|
||||
|
||||
const selectedRows = computed(() => table.getSelectedRowModel().rows)
|
||||
const selectedCount = computed(() => selectedRows.value.length || 0)
|
||||
function handleConfirm() {
|
||||
if (confirmValue.value !== CONFIRM_WORD) {
|
||||
toast.error(`Please type "${CONFIRM_WORD}" to confirm deletion.`)
|
||||
return
|
||||
}
|
||||
|
||||
openModel.value = false
|
||||
|
||||
toast.promise(new Promise(resolve => setTimeout(resolve, 2000)), {
|
||||
loading: 'Deleting tasks...',
|
||||
success: () => {
|
||||
table.resetRowSelection()
|
||||
return `Successfully deleted ${selectedRows.value.length} tasks.`
|
||||
},
|
||||
error: 'Failed to delete tasks.',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDialog
|
||||
v-model:open="openModel"
|
||||
confirm-button-text="Delete"
|
||||
destructive
|
||||
:disabled="confirmValue.trim() !== CONFIRM_WORD"
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<template #title>
|
||||
Delete {{ selectedCount }} tasks?
|
||||
</template>
|
||||
<template #description>
|
||||
Are you sure you want to delete the selected tasks? <br>
|
||||
This action cannot be undone.
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<UiLabel class="my-4 flex flex-col items-start gap-1.5">
|
||||
<span>Confirm by typing {{ CONFIRM_WORD }}:</span>
|
||||
<UiInput
|
||||
v-model="confirmValue"
|
||||
:placeholder="`Type "${CONFIRM_WORD}" to confirm.`"
|
||||
/>
|
||||
</UiLabel>
|
||||
|
||||
<UiAlert variant="destructive">
|
||||
<UiAlertTitle>Warning!</UiAlertTitle>
|
||||
<UiAlertDescription>
|
||||
Please be careful, this operation can not be rolled back.
|
||||
</UiAlertDescription>
|
||||
</UiAlert>
|
||||
</template>
|
||||
</ConfirmDialog>
|
||||
</template>
|
||||
44
monisuo-admin/src/pages/tasks/components/task-delete.vue
Normal file
44
monisuo-admin/src/pages/tasks/components/task-delete.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { ModalClose, ModalDescription, ModalFooter, ModalHeader, ModalTitle } from '@/components/prop-ui/modal'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
}>()
|
||||
|
||||
function handleRemove() {
|
||||
toast(`The following task has been deleted:`, {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(props.task, null, 2))),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
Delete this task: {{ task.id }} ?
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
You are about to delete a task with the ID {{ task.id }}. This action cannot be undone.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose as-child>
|
||||
<UiButton variant="outline">
|
||||
Cancel
|
||||
</UiButton>
|
||||
</ModalClose>
|
||||
|
||||
<ModalClose as-child>
|
||||
<UiButton variant="destructive" @click="handleRemove">
|
||||
Delete
|
||||
</UiButton>
|
||||
</ModalClose>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</template>
|
||||
133
monisuo-admin/src/pages/tasks/components/task-form.vue
Normal file
133
monisuo-admin/src/pages/tasks/components/task-form.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { FormField } from '@/components/ui/form'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
import type { TaskValidator } from '../validators/task.validator'
|
||||
|
||||
import { labels, priorities, statuses } from '../data/data'
|
||||
import { taskValidator } from '../validators/task.validator'
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task | null
|
||||
}>()
|
||||
const emits = defineEmits(['close'])
|
||||
|
||||
const formSchema = toTypedSchema(taskValidator)
|
||||
|
||||
const initialValues = reactive<TaskValidator>({
|
||||
title: props.task ? props.task.title : '',
|
||||
status: props.task ? props.task.status : 'backlog',
|
||||
label: props.task ? props.task.label : 'feature',
|
||||
priority: props.task ? props.task.priority : 'medium',
|
||||
})
|
||||
|
||||
const { isFieldDirty, handleSubmit } = useForm({
|
||||
validationSchema: formSchema,
|
||||
initialValues,
|
||||
})
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast('You submitted the following values:', {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
|
||||
})
|
||||
emits('close')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form class="w-2/3 space-y-6" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="title" :validate-on-blur="!isFieldDirty">
|
||||
<UiFormItem>
|
||||
<UiFormLabel>Title</UiFormLabel>
|
||||
<UiFormControl>
|
||||
<UiInput type="text" placeholder="shadcn" v-bind="componentField" />
|
||||
</UiFormControl>
|
||||
<UiFormDescription />
|
||||
<UiFormMessage />
|
||||
</UiFormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="status" :validate-on-blur="!isFieldDirty">
|
||||
<UiFormItem>
|
||||
<UiFormLabel>status</UiFormLabel>
|
||||
<UiFormControl>
|
||||
<UiSelect v-bind="componentField">
|
||||
<UiSelectTrigger class="w-[180px]">
|
||||
<UiSelectValue placeholder="Select a status" />
|
||||
</UiSelectTrigger>
|
||||
<UiSelectContent>
|
||||
<UiSelectGroup>
|
||||
<UiSelectItem v-for="status in statuses" :key="status.value" :value="status.value">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="status.icon" class="size-4 shrink-0" />
|
||||
{{ status.label }}
|
||||
</div>
|
||||
</UiSelectItem>
|
||||
</UiSelectGroup>
|
||||
</UiSelectContent>
|
||||
</UiSelect>
|
||||
</UiFormControl>
|
||||
<UiFormDescription />
|
||||
<UiFormMessage />
|
||||
</UiFormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="label" :validate-on-blur="!isFieldDirty">
|
||||
<UiFormItem>
|
||||
<UiFormLabel>label</UiFormLabel>
|
||||
<UiFormControl>
|
||||
<UiRadioGroup
|
||||
class="flex flex-col space-y-1"
|
||||
v-bind="componentField"
|
||||
>
|
||||
<UiFormItem
|
||||
v-for="label in labels" :key="label.value"
|
||||
class="flex items-center space-y-0 gap-x-3"
|
||||
>
|
||||
<UiFormControl>
|
||||
<UiRadioGroupItem :value="label.value" />
|
||||
</UiFormControl>
|
||||
<UiFormLabel class="font-normal">
|
||||
{{ label.label }}
|
||||
</UiFormLabel>
|
||||
</UiFormItem>
|
||||
</UiRadioGroup>
|
||||
</UiFormControl>
|
||||
<UiFormDescription />
|
||||
<UiFormMessage />
|
||||
</UiFormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="priority" :validate-on-blur="!isFieldDirty">
|
||||
<UiFormItem>
|
||||
<UiFormLabel>priority</UiFormLabel>
|
||||
<UiFormControl>
|
||||
<UiRadioGroup
|
||||
class="flex flex-col space-y-1"
|
||||
v-bind="componentField"
|
||||
>
|
||||
<UiFormItem
|
||||
v-for="priority in priorities" :key="priority.value"
|
||||
class="flex items-center space-y-0 gap-x-3"
|
||||
>
|
||||
<UiFormControl>
|
||||
<UiRadioGroupItem :value="priority.value" />
|
||||
</UiFormControl>
|
||||
<UiFormLabel class="font-normal">
|
||||
{{ priority.label }}
|
||||
</UiFormLabel>
|
||||
</UiFormItem>
|
||||
</UiRadioGroup>
|
||||
</UiFormControl>
|
||||
<UiFormDescription />
|
||||
<UiFormMessage />
|
||||
</UiFormItem>
|
||||
</FormField>
|
||||
|
||||
<UiButton type="submit">
|
||||
Submit
|
||||
</UiButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
68
monisuo-admin/src/pages/tasks/components/task-import.vue
Normal file
68
monisuo-admin/src/pages/tasks/components/task-import.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Modal, ModalContent, ModalDescription, ModalFooter, ModalHeader, ModalTitle, ModalTrigger } from '@/components/prop-ui/modal'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const file = ref()
|
||||
const error = ref()
|
||||
|
||||
watch(file, () => {
|
||||
error.value = null
|
||||
})
|
||||
watch(isOpen, () => {
|
||||
file.value = null
|
||||
})
|
||||
|
||||
function onSubmit() {
|
||||
error.value = null
|
||||
|
||||
if (!file.value) {
|
||||
error.value = 'File is required'
|
||||
return
|
||||
}
|
||||
|
||||
toast('You submitted the following values:', {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(file.value, null, 2))),
|
||||
})
|
||||
isOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="isOpen">
|
||||
<ModalTrigger as-child>
|
||||
<UiButton variant="outline">
|
||||
Import
|
||||
<Download />
|
||||
</UiButton>
|
||||
</ModalTrigger>
|
||||
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
Import Tasks
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
Import tasks quickly from a CSV file.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<div class="grid w-full max-w-sm items-center gap-1.5">
|
||||
<UiLabel>File</UiLabel>
|
||||
<UiInput id="file" v-model="file" type="file" />
|
||||
<span v-if="error" class="text-destructive">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<ModalFooter>
|
||||
<UiButton variant="secondary" @click="isOpen = false">
|
||||
Cancel
|
||||
</UiButton>
|
||||
<UiButton @click="onSubmit">
|
||||
Import
|
||||
</UiButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { ModalDescription, ModalHeader, ModalTitle } from '@/components/prop-ui/modal'
|
||||
|
||||
import type { Task } from '../data/schema'
|
||||
|
||||
import TaskForm from './task-form.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task | null
|
||||
}>()
|
||||
defineEmits(['close'])
|
||||
|
||||
const task = computed(() => props.task)
|
||||
const title = computed(() => task.value?.id ? `Edit Task` : 'New Task')
|
||||
const description = computed(() => task.value?.id ? `Edit task ${task.value.id}` : 'Create new task')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{{ title }}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{{ description }}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<TaskForm class="mt-2" :task="task" @close="$emit('close')" />
|
||||
</div>
|
||||
</template>
|
||||
72
monisuo-admin/src/pages/tasks/data/data.ts
Normal file
72
monisuo-admin/src/pages/tasks/data/data.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleHelp,
|
||||
CirclePlus,
|
||||
TimerOff,
|
||||
} from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
export const labels = [
|
||||
{
|
||||
value: 'bug',
|
||||
label: 'Bug',
|
||||
},
|
||||
{
|
||||
value: 'feature',
|
||||
label: 'Feature',
|
||||
},
|
||||
{
|
||||
value: 'documentation',
|
||||
label: 'Documentation',
|
||||
},
|
||||
]
|
||||
|
||||
export const statuses = [
|
||||
{
|
||||
value: 'backlog',
|
||||
label: 'Backlog',
|
||||
icon: h(CircleHelp),
|
||||
},
|
||||
{
|
||||
value: 'todo',
|
||||
label: 'Todo',
|
||||
icon: h(Circle),
|
||||
},
|
||||
{
|
||||
value: 'in progress',
|
||||
label: 'In Progress',
|
||||
icon: h(TimerOff),
|
||||
},
|
||||
{
|
||||
value: 'done',
|
||||
label: 'Done',
|
||||
icon: h(CircleCheck),
|
||||
},
|
||||
{
|
||||
value: 'canceled',
|
||||
label: 'Canceled',
|
||||
icon: h(CirclePlus),
|
||||
},
|
||||
]
|
||||
|
||||
export const priorities = [
|
||||
{
|
||||
value: 'low',
|
||||
label: 'Low',
|
||||
icon: h(ArrowDown),
|
||||
},
|
||||
{
|
||||
value: 'medium',
|
||||
label: 'Medium',
|
||||
icon: h(ArrowRight),
|
||||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: 'High',
|
||||
icon: h(ArrowUp),
|
||||
},
|
||||
]
|
||||
13
monisuo-admin/src/pages/tasks/data/schema.ts
Normal file
13
monisuo-admin/src/pages/tasks/data/schema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// We're keeping a simple non-relational schema here.
|
||||
// IRL, you will have a schema for your data models.
|
||||
export const taskSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
label: z.string(),
|
||||
priority: z.string(),
|
||||
})
|
||||
|
||||
export type Task = z.infer<typeof taskSchema>
|
||||
702
monisuo-admin/src/pages/tasks/data/tasks.json
Normal file
702
monisuo-admin/src/pages/tasks/data/tasks.json
Normal file
@@ -0,0 +1,702 @@
|
||||
[
|
||||
{
|
||||
"id": "TASK-8782",
|
||||
"title": "You can't compress the program without quantifying the open-source SSD pixel!",
|
||||
"status": "in progress",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7878",
|
||||
"title": "Try to calculate the EXE feed, maybe it will index the multi-byte pixel!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7839",
|
||||
"title": "We need to bypass the neural TCP card!",
|
||||
"status": "todo",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5562",
|
||||
"title": "The SAS interface is down, bypass the open-source pixel so we can back up the PNG bandwidth!",
|
||||
"status": "backlog",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8686",
|
||||
"title": "I'll parse the wireless SSL protocol, that should driver the API panel!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1280",
|
||||
"title": "Use the digital TLS panel, then you can transmit the haptic system!",
|
||||
"status": "done",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7262",
|
||||
"title": "The UTF8 application is down, parse the neural bandwidth so we can back up the PNG firewall!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1138",
|
||||
"title": "Generating the driver won't do anything, we need to quantify the 1080p SMTP bandwidth!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7184",
|
||||
"title": "We need to program the back-end THX pixel!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5160",
|
||||
"title": "Calculating the bus won't do anything, we need to navigate the back-end JSON protocol!",
|
||||
"status": "in progress",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5618",
|
||||
"title": "Generating the driver won't do anything, we need to index the online SSL application!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6699",
|
||||
"title": "I'll transmit the wireless JBOD capacitor, that should hard drive the SSD feed!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-2858",
|
||||
"title": "We need to override the online UDP bus!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9864",
|
||||
"title": "I'll reboot the 1080p FTP panel, that should matrix the HEX hard drive!",
|
||||
"status": "done",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8404",
|
||||
"title": "We need to generate the virtual HEX alarm!",
|
||||
"status": "in progress",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5365",
|
||||
"title": "Backing up the pixel won't do anything, we need to transmit the primary IB array!",
|
||||
"status": "in progress",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1780",
|
||||
"title": "The CSS feed is down, index the bluetooth transmitter so we can compress the CLI protocol!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6938",
|
||||
"title": "Use the redundant SCSI application, then you can hack the optical alarm!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9885",
|
||||
"title": "We need to compress the auxiliary VGA driver!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3216",
|
||||
"title": "Transmitting the transmitter won't do anything, we need to compress the virtual HDD sensor!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9285",
|
||||
"title": "The IP monitor is down, copy the haptic alarm so we can generate the HTTP transmitter!",
|
||||
"status": "todo",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1024",
|
||||
"title": "Overriding the microchip won't do anything, we need to transmit the digital OCR transmitter!",
|
||||
"status": "in progress",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7068",
|
||||
"title": "You can't generate the capacitor without indexing the wireless HEX pixel!",
|
||||
"status": "canceled",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6502",
|
||||
"title": "Navigating the microchip won't do anything, we need to bypass the back-end SQL bus!",
|
||||
"status": "todo",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5326",
|
||||
"title": "We need to hack the redundant UTF8 transmitter!",
|
||||
"status": "todo",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6274",
|
||||
"title": "Use the virtual PCI circuit, then you can parse the bluetooth alarm!",
|
||||
"status": "canceled",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1571",
|
||||
"title": "I'll input the neural DRAM circuit, that should protocol the SMTP interface!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9518",
|
||||
"title": "Compressing the interface won't do anything, we need to compress the online SDD matrix!",
|
||||
"status": "canceled",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5581",
|
||||
"title": "I'll synthesize the digital COM pixel, that should transmitter the UTF8 protocol!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-2197",
|
||||
"title": "Parsing the feed won't do anything, we need to copy the bluetooth DRAM bus!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8484",
|
||||
"title": "We need to parse the solid state UDP firewall!",
|
||||
"status": "in progress",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9892",
|
||||
"title": "If we back up the application, we can get to the UDP application through the multi-byte THX capacitor!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9616",
|
||||
"title": "We need to synthesize the cross-platform ASCII pixel!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9744",
|
||||
"title": "Use the back-end IP card, then you can input the solid state hard drive!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1376",
|
||||
"title": "Generating the alarm won't do anything, we need to generate the mobile IP capacitor!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7382",
|
||||
"title": "If we back up the firewall, we can get to the RAM alarm through the primary UTF8 pixel!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-2290",
|
||||
"title": "I'll compress the virtual JSON panel, that should application the UTF8 bus!",
|
||||
"status": "canceled",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1533",
|
||||
"title": "You can't input the firewall without overriding the wireless TCP firewall!",
|
||||
"status": "done",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4920",
|
||||
"title": "Bypassing the hard drive won't do anything, we need to input the bluetooth JSON program!",
|
||||
"status": "in progress",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5168",
|
||||
"title": "If we synthesize the bus, we can get to the IP panel through the virtual TLS array!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7103",
|
||||
"title": "We need to parse the multi-byte EXE bandwidth!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4314",
|
||||
"title": "If we compress the program, we can get to the XML alarm through the multi-byte COM matrix!",
|
||||
"status": "in progress",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3415",
|
||||
"title": "Use the cross-platform XML application, then you can quantify the solid state feed!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8339",
|
||||
"title": "Try to calculate the DNS interface, maybe it will input the bluetooth capacitor!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6995",
|
||||
"title": "Try to hack the XSS bandwidth, maybe it will override the bluetooth matrix!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8053",
|
||||
"title": "If we connect the program, we can get to the UTF8 matrix through the digital UDP protocol!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4336",
|
||||
"title": "If we synthesize the microchip, we can get to the SAS sensor through the optical UDP program!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8790",
|
||||
"title": "I'll back up the optical COM alarm, that should alarm the RSS capacitor!",
|
||||
"status": "done",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8980",
|
||||
"title": "Try to navigate the SQL transmitter, maybe it will back up the virtual firewall!",
|
||||
"status": "canceled",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7342",
|
||||
"title": "Use the neural CLI card, then you can parse the online port!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5608",
|
||||
"title": "I'll hack the haptic SSL program, that should bus the UDP transmitter!",
|
||||
"status": "canceled",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1606",
|
||||
"title": "I'll generate the bluetooth PNG firewall, that should pixel the SSL driver!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7872",
|
||||
"title": "Transmitting the circuit won't do anything, we need to reboot the 1080p RSS monitor!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4167",
|
||||
"title": "Use the cross-platform SMS circuit, then you can synthesize the optical feed!",
|
||||
"status": "canceled",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9581",
|
||||
"title": "You can't index the port without hacking the cross-platform XSS monitor!",
|
||||
"status": "backlog",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-8806",
|
||||
"title": "We need to bypass the back-end SSL panel!",
|
||||
"status": "done",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6542",
|
||||
"title": "Try to quantify the RSS firewall, maybe it will quantify the open-source system!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6806",
|
||||
"title": "The VGA protocol is down, reboot the back-end matrix so we can parse the CSS panel!",
|
||||
"status": "canceled",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9549",
|
||||
"title": "You can't bypass the bus without connecting the neural JBOD bus!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1075",
|
||||
"title": "Backing up the driver won't do anything, we need to parse the redundant RAM pixel!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1427",
|
||||
"title": "Use the auxiliary PCI circuit, then you can calculate the cross-platform interface!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1907",
|
||||
"title": "Hacking the circuit won't do anything, we need to back up the online DRAM system!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4309",
|
||||
"title": "If we generate the system, we can get to the TCP sensor through the optical GB pixel!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3973",
|
||||
"title": "I'll parse the back-end ADP array, that should bandwidth the RSS bandwidth!",
|
||||
"status": "todo",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7962",
|
||||
"title": "Use the wireless RAM program, then you can hack the cross-platform feed!",
|
||||
"status": "canceled",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3360",
|
||||
"title": "You can't quantify the program without synthesizing the neural OCR interface!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9887",
|
||||
"title": "Use the auxiliary ASCII sensor, then you can connect the solid state port!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3649",
|
||||
"title": "I'll input the virtual USB system, that should circuit the DNS monitor!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3586",
|
||||
"title": "If we quantify the circuit, we can get to the CLI feed through the mobile SMS hard drive!",
|
||||
"status": "in progress",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5150",
|
||||
"title": "I'll hack the wireless XSS port, that should transmitter the IP interface!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3652",
|
||||
"title": "The SQL interface is down, override the optical bus so we can program the ASCII interface!",
|
||||
"status": "backlog",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6884",
|
||||
"title": "Use the digital PCI circuit, then you can synthesize the multi-byte microchip!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1591",
|
||||
"title": "We need to connect the mobile XSS driver!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3802",
|
||||
"title": "Try to override the ASCII protocol, maybe it will parse the virtual matrix!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7253",
|
||||
"title": "Programming the capacitor won't do anything, we need to bypass the neural IB hard drive!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9739",
|
||||
"title": "We need to hack the multi-byte HDD bus!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4424",
|
||||
"title": "Try to hack the HEX alarm, maybe it will connect the optical pixel!",
|
||||
"status": "in progress",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3922",
|
||||
"title": "You can't back up the capacitor without generating the wireless PCI program!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4921",
|
||||
"title": "I'll index the open-source IP feed, that should system the GB application!",
|
||||
"status": "canceled",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5814",
|
||||
"title": "We need to calculate the 1080p AGP feed!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-2645",
|
||||
"title": "Synthesizing the system won't do anything, we need to navigate the multi-byte HDD firewall!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4535",
|
||||
"title": "Try to copy the JSON circuit, maybe it will connect the wireless feed!",
|
||||
"status": "in progress",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4463",
|
||||
"title": "We need to copy the solid state AGP monitor!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9745",
|
||||
"title": "If we connect the protocol, we can get to the GB system through the bluetooth PCI microchip!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-2080",
|
||||
"title": "If we input the bus, we can get to the RAM matrix through the auxiliary RAM card!",
|
||||
"status": "todo",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3838",
|
||||
"title": "I'll bypass the online TCP application, that should panel the AGP system!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-1340",
|
||||
"title": "We need to navigate the virtual PNG circuit!",
|
||||
"status": "todo",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6665",
|
||||
"title": "If we parse the monitor, we can get to the SSD hard drive through the cross-platform AGP alarm!",
|
||||
"status": "canceled",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7585",
|
||||
"title": "If we calculate the hard drive, we can get to the SSL program through the multi-byte CSS microchip!",
|
||||
"status": "backlog",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6319",
|
||||
"title": "We need to copy the multi-byte SCSI program!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4369",
|
||||
"title": "Try to input the SCSI bus, maybe it will generate the 1080p pixel!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-9035",
|
||||
"title": "We need to override the solid state PNG array!",
|
||||
"status": "canceled",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3970",
|
||||
"title": "You can't index the transmitter without quantifying the haptic ASCII card!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4473",
|
||||
"title": "You can't bypass the protocol without overriding the neural RSS program!",
|
||||
"status": "todo",
|
||||
"label": "documentation",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-4136",
|
||||
"title": "You can't hack the hard drive without hacking the primary JSON program!",
|
||||
"status": "canceled",
|
||||
"label": "bug",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-3939",
|
||||
"title": "Use the back-end SQL firewall, then you can connect the neural hard drive!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"id": "TASK-2007",
|
||||
"title": "I'll input the back-end USB protocol, that should bandwidth the PCI system!",
|
||||
"status": "backlog",
|
||||
"label": "bug",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-7516",
|
||||
"title": "Use the primary SQL program, then you can generate the auxiliary transmitter!",
|
||||
"status": "done",
|
||||
"label": "documentation",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "TASK-6906",
|
||||
"title": "Try to back up the DRAM system, maybe it will reboot the online transmitter!",
|
||||
"status": "done",
|
||||
"label": "feature",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "TASK-5207",
|
||||
"title": "The SMS interface is down, copy the bluetooth bus so we can quantify the VGA card!",
|
||||
"status": "in progress",
|
||||
"label": "bug",
|
||||
"priority": "low"
|
||||
}
|
||||
]
|
||||
25
monisuo-admin/src/pages/tasks/index.vue
Normal file
25
monisuo-admin/src/pages/tasks/index.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
import { columns } from './components/columns'
|
||||
import DataTable from './components/data-table.vue'
|
||||
import TaskCreate from './components/task-create.vue'
|
||||
import TaskImport from './components/task-import.vue'
|
||||
import tasks from './data/tasks.json'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="Tasks"
|
||||
description="Tasks description"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<TaskImport />
|
||||
<TaskCreate />
|
||||
</template>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :data="tasks" :columns="columns" />
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
10
monisuo-admin/src/pages/tasks/validators/task.validator.ts
Normal file
10
monisuo-admin/src/pages/tasks/validators/task.validator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import z from 'zod'
|
||||
|
||||
export const taskValidator = z.object({
|
||||
title: z.string().min(2).max(50),
|
||||
status: z.string(),
|
||||
label: z.string(),
|
||||
priority: z.string(),
|
||||
})
|
||||
|
||||
export type TaskValidator = z.infer<typeof taskValidator>
|
||||
86
monisuo-admin/src/pages/users/components/columns.ts
Normal file
86
monisuo-admin/src/pages/users/components/columns.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ColumnDef } from '@tanstack/vue-table'
|
||||
|
||||
import { h } from 'vue'
|
||||
|
||||
import { DataTableColumnHeader, SelectColumn } from '@/components/data-table'
|
||||
import { Copy } from '@/components/prop-ui/copy'
|
||||
import Badge from '@/components/ui/badge/Badge.vue'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
|
||||
import { callTypes, userTypes } from '../data/data'
|
||||
import DataTableRowActions from './data-table-row-actions.vue'
|
||||
|
||||
export const columns: ColumnDef<User>[] = [
|
||||
SelectColumn as ColumnDef<User>,
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'username' }),
|
||||
cell: ({ row }) => h('div', { }, row.getValue('username')),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
enableResizing: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'Email' }),
|
||||
cell: ({ row }) => h('div', { }, [
|
||||
h('span', {}, row.getValue('email')),
|
||||
h(Copy, { class: 'ml-2', size: 'sm', content: (row.getValue('email') || '') as string }),
|
||||
]),
|
||||
enableSorting: false,
|
||||
enableResizing: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'phoneNumber',
|
||||
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'PhoneNumber' }),
|
||||
cell: ({ row }) => h('div', { }, row.getValue('phoneNumber')),
|
||||
enableSorting: false,
|
||||
enableResizing: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'Status' }),
|
||||
|
||||
cell: ({ row }) => {
|
||||
const callType = callTypes.find(callType => callType.value === row.getValue('status'))
|
||||
|
||||
if (!callType)
|
||||
return null
|
||||
|
||||
return h(Badge, { class: `${callType.style || ''}`, variant: 'outline' }, () => callType.label)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id))
|
||||
},
|
||||
enableResizing: true,
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => h(DataTableColumnHeader<User>, { column, title: 'Role' }),
|
||||
cell: ({ row }) => {
|
||||
const priority = userTypes.find(
|
||||
priority => priority.value === row.getValue('role'),
|
||||
)
|
||||
|
||||
if (!priority)
|
||||
return null
|
||||
|
||||
return h('div', { class: 'flex items-center' }, [
|
||||
priority.icon && h(priority.icon, { class: 'mr-2 h-4 w-4 text-muted-foreground' }),
|
||||
h('span', {}, priority.label),
|
||||
])
|
||||
},
|
||||
enableSorting: false,
|
||||
enableResizing: true,
|
||||
},
|
||||
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => h(DataTableRowActions, { row }),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { Row } from '@tanstack/vue-table'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Ellipsis } from 'lucide-vue-next'
|
||||
|
||||
import { Modal, ModalContent } from '@/components/prop-ui/modal'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
|
||||
interface DataTableRowActionsProps {
|
||||
row: Row<User>
|
||||
}
|
||||
const props = defineProps<DataTableRowActionsProps>()
|
||||
const user = computed(() => props.row.original)
|
||||
const isOpen = ref(false)
|
||||
|
||||
const showComponent = shallowRef<Component | null>(null)
|
||||
type TCommand = 'edit' | 'delete'
|
||||
|
||||
const componentLoader: Record<TCommand, () => Promise<{ default: Component }>> = {
|
||||
edit: () => import('./user-resource.vue'),
|
||||
delete: () => import('./user-delete.vue'),
|
||||
}
|
||||
|
||||
async function handleSelect(command: TCommand) {
|
||||
try {
|
||||
const { default: component } = await componentLoader[command]()
|
||||
showComponent.value = component
|
||||
isOpen.value = true
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`Failed to load component for "${command}"`, e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="isOpen">
|
||||
<UiDropdownMenu>
|
||||
<UiDropdownMenuTrigger as-child>
|
||||
<UiButton
|
||||
variant="ghost"
|
||||
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
|
||||
>
|
||||
<Ellipsis class="size-4" />
|
||||
<span class="sr-only">Open menu</span>
|
||||
</UiButton>
|
||||
</UiDropdownMenuTrigger>
|
||||
<UiDropdownMenuContent align="end" class="w-[160px]">
|
||||
<UiDropdownMenuItem @click.stop="handleSelect('edit')">
|
||||
Edit
|
||||
</UiDropdownMenuItem>
|
||||
|
||||
<UiDropdownMenuItem @click.stop="handleSelect('delete')">
|
||||
Delete
|
||||
<UiDropdownMenuShortcut>⌘⌫</UiDropdownMenuShortcut>
|
||||
</UiDropdownMenuItem>
|
||||
</UiDropdownMenuContent>
|
||||
</UiDropdownMenu>
|
||||
|
||||
<ModalContent>
|
||||
<component :is="showComponent" :user="user" @close="isOpen = false" />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { Table } from '@tanstack/vue-table'
|
||||
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { DataTableFacetedFilter, DataTableViewOptions } from '@/components/data-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
|
||||
import { callTypes, userTypes } from '../data/data'
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
table: Table<User>
|
||||
}
|
||||
|
||||
const props = defineProps<DataTableToolbarProps>()
|
||||
|
||||
const isFiltered = computed(() => props.table.getState().columnFilters.length > 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
placeholder="Filter users by username..."
|
||||
:model-value="(table.getColumn('username')?.getFilterValue() as string) ?? ''"
|
||||
class="h-8 w-[150px] lg:w-[250px]"
|
||||
@input="table.getColumn('username')?.setFilterValue($event.target.value)"
|
||||
/>
|
||||
<DataTableFacetedFilter
|
||||
v-if="table.getColumn('status')"
|
||||
:column="table.getColumn('status')"
|
||||
title="Status"
|
||||
:options="callTypes"
|
||||
/>
|
||||
<DataTableFacetedFilter
|
||||
v-if="table.getColumn('role')"
|
||||
:column="table.getColumn('role')"
|
||||
title="Role"
|
||||
:options="userTypes"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="isFiltered"
|
||||
variant="ghost"
|
||||
class="h-8 px-2 lg:px-3"
|
||||
@click="table.resetColumnFilters()"
|
||||
>
|
||||
Reset
|
||||
<X class="size-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
<DataTableViewOptions :table="table" />
|
||||
</div>
|
||||
</template>
|
||||
21
monisuo-admin/src/pages/users/components/data-table.vue
Normal file
21
monisuo-admin/src/pages/users/components/data-table.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DataTableProps } from '@/components/data-table'
|
||||
|
||||
import { DataTable, useGenerateVueTable } from '@/components/data-table'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
|
||||
import DataTableToolbar from './data-table-toolbar.vue'
|
||||
|
||||
const props = defineProps<DataTableProps<User>>()
|
||||
|
||||
const table = useGenerateVueTable<User>(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable :columns :data :loading :table>
|
||||
<template #toolbar>
|
||||
<DataTableToolbar :table class="w-full overflow-x-auto" />
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
24
monisuo-admin/src/pages/users/components/user-create.vue
Normal file
24
monisuo-admin/src/pages/users/components/user-create.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import { UserRoundPlus } from 'lucide-vue-next'
|
||||
|
||||
import { Modal, ModalContent, ModalTrigger } from '@/components/prop-ui/modal'
|
||||
|
||||
import UserResource from './user-resource.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="isOpen">
|
||||
<ModalTrigger as-child>
|
||||
<UiButton>
|
||||
<UserRoundPlus />
|
||||
Create User
|
||||
</UiButton>
|
||||
</ModalTrigger>
|
||||
|
||||
<ModalContent>
|
||||
<UserResource @close="isOpen = false" />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</template>
|
||||
51
monisuo-admin/src/pages/users/components/user-delete.vue
Normal file
51
monisuo-admin/src/pages/users/components/user-delete.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { ModalClose, ModalDescription, ModalFooter, ModalHeader, ModalTitle } from '@/components/prop-ui/modal'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
|
||||
const { user } = defineProps<{
|
||||
user: User
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'remove'): void
|
||||
}>()
|
||||
|
||||
function handleRemove() {
|
||||
toast(`The following task has been deleted:`, {
|
||||
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(user, null, 2))),
|
||||
})
|
||||
|
||||
emits('remove')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
Delete this user: {{ user.username }} ?
|
||||
</ModalTitle>
|
||||
|
||||
<ModalDescription>
|
||||
You are about to delete a user with the ID {{ user.id }}. This action cannot be undone.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose as-child>
|
||||
<UiButton variant="outline">
|
||||
Cancel
|
||||
</UiButton>
|
||||
</ModalClose>
|
||||
|
||||
<ModalClose as-child>
|
||||
<UiButton variant="destructive" @click="handleRemove">
|
||||
Delete
|
||||
</UiButton>
|
||||
</ModalClose>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</template>
|
||||
161
monisuo-admin/src/pages/users/components/user-form.vue
Normal file
161
monisuo-admin/src/pages/users/components/user-form.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
import type { UserValidator } from '../validators/user.validator'
|
||||
|
||||
import { userValidator } from '../validators/user.validator'
|
||||
|
||||
const { user } = defineProps<{
|
||||
user?: User
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const roles = ['superadmin', 'admin', 'cashier', 'manager'] as const
|
||||
const status = ['active', 'inactive', 'invited', 'suspended'] as const
|
||||
|
||||
const initialValues = reactive<UserValidator>({
|
||||
firstName: user?.firstName || '',
|
||||
lastName: user?.lastName || '',
|
||||
username: user?.username || '',
|
||||
email: user?.email || '',
|
||||
phoneNumber: user?.phoneNumber || '',
|
||||
status: user?.status || 'active',
|
||||
role: user?.role || 'cashier',
|
||||
})
|
||||
|
||||
const userFormSchema = toTypedSchema(userValidator)
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: userFormSchema,
|
||||
initialValues,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
const submitUser = { ...values }
|
||||
if (user) {
|
||||
submitUser.id = user.id
|
||||
}
|
||||
toast('You submitted the following values:', {
|
||||
description: h(
|
||||
'pre',
|
||||
{ class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' },
|
||||
h('code', { class: 'text-white' }, JSON.stringify(submitUser, null, 2)),
|
||||
),
|
||||
})
|
||||
|
||||
emits('close')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-h-[500px] overflow-y-auto">
|
||||
<form class="space-y-8" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="firstName">
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="lastName">
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormItem>
|
||||
<FormLabel>User Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="phoneNumber">
|
||||
<FormItem>
|
||||
<FormLabel>Phone Number</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="status">
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<FormControl>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="state in status" :key="state" :value="state">
|
||||
{{ state }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="role">
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<FormControl>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||
{{ role }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
SaveChanges
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { Send } from 'lucide-vue-next'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import type { UserInviteValidator } from '../validators/user-invite.validator'
|
||||
|
||||
import { userInviteValidator } from '../validators/user-invite.validator'
|
||||
|
||||
const roles = ['superadmin', 'admin', 'cashier', 'manager'] as const
|
||||
|
||||
const initialValues = reactive<UserInviteValidator>({
|
||||
email: '',
|
||||
role: 'cashier',
|
||||
description: '',
|
||||
})
|
||||
const userInviteFormSchema = toTypedSchema(userInviteValidator)
|
||||
const { handleSubmit } = useForm({
|
||||
validationSchema: userInviteFormSchema,
|
||||
initialValues,
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit((values) => {
|
||||
toast('You submitted the following values:', {
|
||||
description: h(
|
||||
'pre',
|
||||
{ class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' },
|
||||
h('code', { class: 'text-white' }, JSON.stringify(values, null, 2)),
|
||||
),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-8" @submit="onSubmit">
|
||||
<FormField v-slot="{ componentField }" name="email">
|
||||
<FormItem>
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="role">
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
<span class="text-destructive"> *</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
<FormControl>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="role in roles" :key="role" :value="role">
|
||||
{{ role }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Description(Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" class="w-full">
|
||||
Invite
|
||||
<Send />
|
||||
</Button>
|
||||
</form>
|
||||
</template>
|
||||
46
monisuo-admin/src/pages/users/components/user-invite.vue
Normal file
46
monisuo-admin/src/pages/users/components/user-invite.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { MailPlus } from 'lucide-vue-next'
|
||||
|
||||
import { Modal, ModalClose, ModalContent, ModalDescription, ModalFooter, ModalHeader, ModalTitle, ModalTrigger, useModal } from '@/components/prop-ui/modal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import UserInviteForm from './user-invite-form.vue'
|
||||
|
||||
const { isDesktop } = useModal()
|
||||
const isOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal v-model:open="isOpen">
|
||||
<ModalTrigger as-child>
|
||||
<Button variant="outline">
|
||||
<MailPlus />
|
||||
Invite User
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle as-child>
|
||||
<div class="flex items-center gap-2">
|
||||
<MailPlus />
|
||||
<span>Invite User</span>
|
||||
</div>
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
Invite new user to join your team by sending them an email invitation. Assign a role to define their access level.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<UserInviteForm />
|
||||
|
||||
<ModalFooter v-if="!isDesktop" class="pt-2">
|
||||
<ModalClose as-child>
|
||||
<Button variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</template>
|
||||
31
monisuo-admin/src/pages/users/components/user-resource.vue
Normal file
31
monisuo-admin/src/pages/users/components/user-resource.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { ModalDescription, ModalHeader, ModalTitle } from '@/components/prop-ui/modal'
|
||||
|
||||
import type { User } from '../data/schema'
|
||||
|
||||
import UserForm from './user-form.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
user?: User
|
||||
}>()
|
||||
defineEmits(['close'])
|
||||
|
||||
const user = computed(() => props.user)
|
||||
const title = computed(() => user.value?.id ? `Edit User` : 'New User')
|
||||
const description = computed(() => user.value?.id ? `Edit user ${user.value.username}` : 'Create new user')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{{ title }}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{{ description }}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<UserForm :user="user" @close="$emit('close')" />
|
||||
</div>
|
||||
</template>
|
||||
50
monisuo-admin/src/pages/users/data/data.ts
Normal file
50
monisuo-admin/src/pages/users/data/data.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Award, BadgeDollarSign, Handshake, Shield } from 'lucide-vue-next'
|
||||
import { h } from 'vue'
|
||||
|
||||
import type { FacetedFilterOption } from '@/components/data-table'
|
||||
|
||||
export const callTypes: (FacetedFilterOption & { style: string })[] = [
|
||||
{
|
||||
label: 'Active',
|
||||
value: 'active',
|
||||
style: 'bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200',
|
||||
},
|
||||
{
|
||||
label: 'Inactive',
|
||||
value: 'inactive',
|
||||
style: 'bg-neutral-300/40 border-neutral-300',
|
||||
},
|
||||
{
|
||||
label: 'Invited',
|
||||
value: 'invited',
|
||||
style: 'bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300',
|
||||
},
|
||||
{
|
||||
label: 'Suspended',
|
||||
value: 'suspended',
|
||||
style: 'bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10',
|
||||
},
|
||||
]
|
||||
|
||||
export const userTypes: FacetedFilterOption[] = [
|
||||
{
|
||||
label: 'Superadmin',
|
||||
value: 'superadmin',
|
||||
icon: h(BadgeDollarSign),
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
icon: h(Handshake),
|
||||
},
|
||||
{
|
||||
label: 'Manager',
|
||||
value: 'manager',
|
||||
icon: h(Award),
|
||||
},
|
||||
{
|
||||
label: 'Cashier',
|
||||
value: 'cashier',
|
||||
icon: h(Shield),
|
||||
},
|
||||
] as const
|
||||
23
monisuo-admin/src/pages/users/data/schema.ts
Normal file
23
monisuo-admin/src/pages/users/data/schema.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const userStatusSchema = z.enum(['active', 'inactive', 'invited', 'suspended'])
|
||||
export type UserStatus = z.infer<typeof userStatusSchema>
|
||||
|
||||
export const userRoleSchema = z.enum(['superadmin', 'admin', 'cashier', 'manager'])
|
||||
export type UserRole = z.infer<typeof userRoleSchema>
|
||||
|
||||
export const userSchema = z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
phoneNumber: z.string(),
|
||||
status: userStatusSchema,
|
||||
role: userRoleSchema,
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
})
|
||||
export type User = z.infer<typeof userSchema>
|
||||
|
||||
export const userListSchema = z.array(userSchema)
|
||||
30
monisuo-admin/src/pages/users/data/users.ts
Normal file
30
monisuo-admin/src/pages/users/data/users.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
export const users = Array.from({ length: 20 }, () => {
|
||||
const firstName = faker.person.firstName()
|
||||
const lastName = faker.person.lastName()
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
firstName,
|
||||
lastName,
|
||||
username: faker.internet
|
||||
.username({ firstName, lastName })
|
||||
.toLocaleLowerCase(),
|
||||
email: faker.internet.email({ firstName }).toLocaleLowerCase(),
|
||||
phoneNumber: faker.phone.number({ style: 'international' }),
|
||||
status: faker.helpers.arrayElement([
|
||||
'active',
|
||||
'inactive',
|
||||
'invited',
|
||||
'suspended',
|
||||
]),
|
||||
role: faker.helpers.arrayElement([
|
||||
'superadmin',
|
||||
'admin',
|
||||
'cashier',
|
||||
'manager',
|
||||
]),
|
||||
createdAt: faker.date.past(),
|
||||
updatedAt: faker.date.recent(),
|
||||
}
|
||||
})
|
||||
39
monisuo-admin/src/pages/users/index.vue
Normal file
39
monisuo-admin/src/pages/users/index.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader } from 'lucide-vue-next'
|
||||
|
||||
import { BasicPage } from '@/components/global-layout'
|
||||
|
||||
import { columns } from './components/columns'
|
||||
import DataTable from './components/data-table.vue'
|
||||
import UserCreate from './components/user-create.vue'
|
||||
import UserInvite from './components/user-invite.vue'
|
||||
import { users } from './data/users'
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
function mockLoading() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
}, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicPage
|
||||
title="Users"
|
||||
description="Users description"
|
||||
sticky
|
||||
>
|
||||
<template #actions>
|
||||
<UserInvite />
|
||||
<UserCreate />
|
||||
<UiButton variant="outline" @click="mockLoading">
|
||||
<Loader />Mock Loading
|
||||
</UiButton>
|
||||
</template>
|
||||
<div class="overflow-x-auto">
|
||||
<DataTable :loading :data="users" :columns="columns" />
|
||||
</div>
|
||||
</BasicPage>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const userInviteValidator = z.object({
|
||||
email: z.email(),
|
||||
role: z.enum(['superadmin', 'admin', 'cashier', 'manager']),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export type UserInviteValidator = z.infer<typeof userInviteValidator>
|
||||
16
monisuo-admin/src/pages/users/validators/user.validator.ts
Normal file
16
monisuo-admin/src/pages/users/validators/user.validator.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { userRoleSchema, userStatusSchema } from '../data/schema'
|
||||
|
||||
export const userValidator = z.object({
|
||||
id: z.string().optional(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
username: z.string().min(1),
|
||||
email: z.email().min(1),
|
||||
phoneNumber: z.string().min(1),
|
||||
status: userStatusSchema,
|
||||
role: userRoleSchema,
|
||||
})
|
||||
|
||||
export type UserValidator = z.infer<typeof userValidator>
|
||||
Reference in New Issue
Block a user