This commit is contained in:
2026-03-22 13:55:23 +08:00
parent c3f196ded4
commit 69099986e0
616 changed files with 38942 additions and 3 deletions

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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 }),
},
]

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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 &quot;${CONFIRM_WORD}&quot; 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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),
},
]

View 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>

View 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"
}
]

View 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>

View 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>

View 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 }),
},
]

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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

View 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)

View 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(),
}
})

View 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>

View File

@@ -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>

View 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>