feat: 初始化项目结构与核心功能

引入项目基础目录结构,包含多语言支持、主要页面与组件、核心游戏逻辑、UI 组件库、加密与本地持久化、自动化 Docker 构建流程、GitHub issue 模板(中英文)、README(中英文)、LICENSE 及开发配置文件。实现 OGame 单机版主要功能模块,为后续开发和扩展奠定基础。
This commit is contained in:
谦君
2025-12-11 14:49:25 +08:00
commit 705ee8c3db
178 changed files with 17258 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="fixed inset-0 bg-black/50" @click="handleClose" />
<div class="relative bg-card border rounded-lg shadow-lg p-6 max-w-md w-full mx-4 z-10">
<h2 class="text-lg font-semibold mb-2">{{ dialogProps?.title }}</h2>
<p class="text-sm text-muted-foreground mb-6 whitespace-pre-line">{{ dialogProps?.message }}</p>
<div class="flex justify-end gap-2">
<Button v-if="dialogProps?.onConfirm" @click="handleClose" variant="outline">{{ t('common.cancel') }}</Button>
<Button @click="handleConfirm" variant="default">{{ t('common.confirm') }}</Button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
interface AlertDialogProps {
title: string
message: string
onConfirm?: () => void
}
const isOpen = ref(false)
const dialogProps = ref<AlertDialogProps | null>(null)
const show = (props: AlertDialogProps) => {
dialogProps.value = props
isOpen.value = true
}
const handleConfirm = () => {
if (dialogProps.value?.onConfirm) {
dialogProps.value.onConfirm()
}
isOpen.value = false
}
const handleClose = () => {
isOpen.value = false
}
defineExpose({ show })
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div v-if="!isUnlocked" class="absolute inset-0 z-10 bg-background/70 backdrop-blur-[2px] rounded-lg flex items-center justify-center">
<div class="text-center p-4 space-y-2">
<div class="flex justify-center">
<div class="rounded-full bg-muted p-2">
<Lock :size="20" class="text-muted-foreground" />
</div>
</div>
<p class="text-xs font-medium text-muted-foreground">{{ t('common.locked') }}</p>
<Button variant="outline" size="sm" @click="showRequirements" class="text-xs">
{{ t('common.viewRequirements') }}
</Button>
</div>
<!-- 前置条件详情对话框 -->
<AlertDialog ref="requirementsDialog" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { BuildingType, TechnologyType } from '@/types/game'
import { Lock } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import AlertDialog from '@/components/AlertDialog.vue'
import * as publicLogic from '@/logic/publicLogic'
interface Props {
requirements?: Partial<Record<BuildingType | TechnologyType, number>>
}
const props = defineProps<Props>()
const gameStore = useGameStore()
const { t } = useI18n()
const { BUILDINGS, TECHNOLOGIES } = useGameConfig()
const requirementsDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
const isUnlocked = computed(() => {
if (!props.requirements || !gameStore.currentPlanet) return true
return publicLogic.checkRequirements(gameStore.currentPlanet, gameStore.player.technologies, props.requirements)
})
const getRequirementsList = (): string => {
if (!props.requirements || !gameStore.currentPlanet) return ''
const lines: string[] = []
for (const [key, requiredLevel] of Object.entries(props.requirements)) {
// 检查是否为建筑类型
if (Object.values(BuildingType).includes(key as BuildingType)) {
const buildingType = key as BuildingType
const currentLevel = gameStore.currentPlanet.buildings[buildingType] || 0
const name = BUILDINGS.value[buildingType]?.name || buildingType
const status = currentLevel >= requiredLevel ? '✓' : '✗'
lines.push(`${status} ${name}: Lv ${requiredLevel} (${t('common.current')}: Lv ${currentLevel})`)
}
// 检查是否为科技类型
else if (Object.values(TechnologyType).includes(key as TechnologyType)) {
const techType = key as TechnologyType
const currentLevel = gameStore.player.technologies[techType] || 0
const name = TECHNOLOGIES.value[techType]?.name || techType
const status = currentLevel >= requiredLevel ? '✓' : '✗'
lines.push(`${status} ${name}: Lv ${requiredLevel} (${t('common.current')}: Lv ${currentLevel})`)
}
}
return lines.join('\n')
}
const showRequirements = () => {
requirementsDialog.value?.show({
title: t('common.requirementsNotMet'),
message: getRequirementsList()
})
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="fixed inset-0 bg-black/50" @click="handleCancel" />
<div class="relative bg-card border rounded-lg shadow-lg p-6 max-w-md w-full mx-4 z-10">
<h2 class="text-lg font-semibold mb-2">{{ dialogProps?.title }}</h2>
<p class="text-sm text-muted-foreground mb-6">{{ dialogProps?.message }}</p>
<div class="flex justify-end gap-3">
<Button @click="handleCancel" variant="outline">{{ t('common.cancel') }}</Button>
<Button @click="handleConfirm" variant="default">{{ t('common.confirm') }}</Button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/composables/useI18n'
const { t } = useI18n()
interface ConfirmDialogProps {
title: string
message: string
onConfirm: () => void
}
const isOpen = ref(false)
const dialogProps = ref<ConfirmDialogProps | null>(null)
const show = (props: ConfirmDialogProps) => {
dialogProps.value = props
isOpen.value = true
}
const handleConfirm = () => {
if (dialogProps.value) {
dialogProps.value.onConfirm()
}
isOpen.value = false
}
const handleCancel = () => {
isOpen.value = false
}
defineExpose({ show })
</script>

View File

@@ -0,0 +1,84 @@
<template>
<Dialog :open="dialogStore.isOpen" @update:open="handleClose">
<DialogContent class="max-w-[calc(100%-1rem)] sm:max-w-[90vw] md:max-w-3xl lg:max-w-4xl max-h-[90vh] flex flex-col p-0">
<!-- 建筑详情 -->
<template v-if="dialogStore.type === 'building' && dialogStore.itemType">
<DialogHeader class="px-6 pt-6 pb-4 shrink-0">
<DialogTitle class="flex items-center gap-2">
{{ t(`buildings.${dialogStore.itemType}`) }}
<Badge variant="outline">{{ t('common.currentLevel') }} {{ dialogStore.currentLevel || 0 }}</Badge>
</DialogTitle>
<DialogDescription>
{{ t(`buildingDescriptions.${dialogStore.itemType}`) }}
</DialogDescription>
</DialogHeader>
<div class="overflow-y-auto px-6 pb-6">
<BuildingDetailView :buildingType="dialogStore.itemType as BuildingType" :currentLevel="dialogStore.currentLevel || 0" />
</div>
</template>
<!-- 科技详情 -->
<template v-else-if="dialogStore.type === 'technology' && dialogStore.itemType">
<DialogHeader class="px-6 pt-6 pb-4 shrink-0">
<DialogTitle class="flex items-center gap-2">
{{ t(`technologies.${dialogStore.itemType}`) }}
<Badge variant="outline">{{ t('common.currentLevel') }} {{ dialogStore.currentLevel || 0 }}</Badge>
</DialogTitle>
<DialogDescription>
{{ t(`technologyDescriptions.${dialogStore.itemType}`) }}
</DialogDescription>
</DialogHeader>
<div class="overflow-y-auto px-6 pb-6">
<TechnologyDetailView :technologyType="dialogStore.itemType as TechnologyType" :currentLevel="dialogStore.currentLevel || 0" />
</div>
</template>
<!-- 舰船详情 -->
<template v-else-if="dialogStore.type === 'ship' && dialogStore.itemType">
<DialogHeader class="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{{ t(`ships.${dialogStore.itemType}`) }}</DialogTitle>
<DialogDescription>
{{ t(`shipDescriptions.${dialogStore.itemType}`) }}
</DialogDescription>
</DialogHeader>
<div class="overflow-y-auto px-6 pb-6">
<ShipDetailView :shipType="dialogStore.itemType as ShipType" />
</div>
</template>
<!-- 防御详情 -->
<template v-else-if="dialogStore.type === 'defense' && dialogStore.itemType">
<DialogHeader class="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{{ t(`defenses.${dialogStore.itemType}`) }}</DialogTitle>
<DialogDescription>
{{ t(`defenseDescriptions.${dialogStore.itemType}`) }}
</DialogDescription>
</DialogHeader>
<div class="overflow-y-auto px-6 pb-6">
<DefenseDetailView :defenseType="dialogStore.itemType as DefenseType" />
</div>
</template>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { useDetailDialogStore } from '@/stores/detailDialogStore'
import { useI18n } from '@/composables/useI18n'
import type { BuildingType, TechnologyType, ShipType, DefenseType } from '@/types/game'
import BuildingDetailView from './detail-views/BuildingDetailView.vue'
import TechnologyDetailView from './detail-views/TechnologyDetailView.vue'
import ShipDetailView from './detail-views/ShipDetailView.vue'
import DefenseDetailView from './detail-views/DefenseDetailView.vue'
const { t } = useI18n()
const dialogStore = useDetailDialogStore()
const handleClose = (open: boolean) => {
if (!open) {
dialogStore.close()
}
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div :class="[colors[type], sizes[size], 'rounded shadow-sm']" />
</template>
<script setup lang="ts">
interface Props {
type: 'metal' | 'crystal' | 'deuterium' | 'darkMatter' | 'energy'
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md'
})
const colors = {
metal: 'bg-gradient-to-br from-slate-400 to-slate-600',
crystal: 'bg-gradient-to-br from-cyan-400 to-blue-600',
deuterium: 'bg-gradient-to-br from-green-400 to-emerald-600',
darkMatter: 'bg-gradient-to-br from-purple-600 to-indigo-900',
energy: 'bg-gradient-to-br from-yellow-400 to-orange-500'
}
const sizes = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5'
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div v-if="!isUnlocked" class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4">
<Card class="max-w-md w-full">
<CardHeader class="text-center">
<div class="flex justify-center mb-4">
<div class="rounded-full bg-muted p-4">
<Lock :size="48" class="text-muted-foreground" />
</div>
</div>
<CardTitle class="text-xl sm:text-2xl">{{ t('common.featureLocked') }}</CardTitle>
<CardDescription class="text-sm sm:text-base">{{ t('common.unlockRequired') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="p-4 bg-muted rounded-lg space-y-2">
<p class="text-sm font-medium text-center">{{ t('common.requiredBuilding') }}:</p>
<div class="flex items-center justify-center gap-2">
<span class="text-base sm:text-lg font-bold">{{ buildingName }}</span>
<Badge variant="default">Lv {{ requiredLevel }}</Badge>
</div>
<p v-if="currentLevel !== undefined" class="text-xs text-center text-muted-foreground">
{{ t('common.currentLevel') }}: Lv {{ currentLevel }}
</p>
</div>
<div class="flex gap-2">
<Button @click="goToBuildings" class="flex-1">
<Building2 :size="16" class="mr-2" />
{{ t('common.goToBuildings') }}
</Button>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { BuildingType } from '@/types/game'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Lock, Building2 } from 'lucide-vue-next'
interface Props {
requiredBuilding: BuildingType
requiredLevel: number
}
const props = defineProps<Props>()
const router = useRouter()
const gameStore = useGameStore()
const { t } = useI18n()
const { BUILDINGS } = useGameConfig()
const buildingName = computed(() => BUILDINGS.value[props.requiredBuilding]?.name || props.requiredBuilding)
const currentLevel = computed(() => {
if (!gameStore.currentPlanet) return 0
return gameStore.currentPlanet.buildings[props.requiredBuilding] || 0
})
const isUnlocked = computed(() => {
return currentLevel.value >= props.requiredLevel
})
const goToBuildings = () => {
router.push('/buildings')
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<div class="space-y-4">
<!-- 建筑等级范围表格 -->
<div class="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20 text-center">{{ t('buildings.levelRange') }}</TableHead>
<TableHead class="text-center">{{ t('resources.metal') }}</TableHead>
<TableHead class="text-center">{{ t('resources.crystal') }}</TableHead>
<TableHead class="text-center">{{ t('resources.deuterium') }}</TableHead>
<TableHead class="text-center">{{ t('buildings.buildTime') }}</TableHead>
<TableHead class="text-center">{{ t('buildings.production') }}</TableHead>
<TableHead class="text-center">{{ t('buildings.consumption') }}</TableHead>
<TableHead class="text-center">{{ t('player.points') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="level in levelRange" :key="level" :class="{ 'bg-muted/50': level === currentLevel }">
<TableCell class="text-center font-medium">
<Badge v-if="level === currentLevel" variant="default">{{ level }}</Badge>
<span v-else>{{ level }}</span>
</TableCell>
<TableCell class="text-center text-sm">{{ formatNumber(getLevelData(level).cost.metal) }}</TableCell>
<TableCell class="text-center text-sm">{{ formatNumber(getLevelData(level).cost.crystal) }}</TableCell>
<TableCell class="text-center text-sm">{{ formatNumber(getLevelData(level).cost.deuterium) }}</TableCell>
<TableCell class="text-center text-sm">{{ formatTime(getLevelData(level).buildTime) }}</TableCell>
<TableCell class="text-center text-sm">
<span v-if="getLevelData(level).production > 0" class="text-green-600 dark:text-green-400">
+{{ formatNumber(getLevelData(level).production) }}/{{ t('resources.perHour') }}
</span>
<span v-else>-</span>
</TableCell>
<TableCell class="text-center text-sm">
<span v-if="getLevelData(level).consumption > 0" class="text-red-600 dark:text-red-400">
-{{ formatNumber(getLevelData(level).consumption) }}
</span>
<span v-else>-</span>
</TableCell>
<TableCell class="text-center text-sm">
<span class="text-primary font-medium">+{{ getLevelData(level).points }}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 累积统计 -->
<div class="grid grid-cols-2 gap-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm">{{ t('buildings.totalCost') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(totalStats.metal) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(totalStats.crystal) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.deuterium') }}:</span>
<span class="font-medium">{{ formatNumber(totalStats.deuterium) }}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm">{{ t('buildings.totalPoints') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold text-primary">{{ formatNumber(totalStats.points) }}</div>
<p class="text-xs text-muted-foreground mt-1">
{{ t('buildings.levelRange') }}: {{ Math.max(0, currentLevel - 10) }} - {{ Math.min(currentLevel + 10, currentLevel + 10) }}
</p>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import type { BuildingType } from '@/types/game'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as buildingLogic from '@/logic/buildingLogic'
import * as pointsLogic from '@/logic/pointsLogic'
const { t } = useI18n()
const props = defineProps<{
buildingType: BuildingType
currentLevel: number
}>()
// 等级范围:当前等级 ±10
const levelRange = computed(() => {
const start = Math.max(0, props.currentLevel - 10)
const end = props.currentLevel + 10
const levels = []
for (let i = start; i <= end; i++) {
levels.push(i)
}
return levels
})
// 获取某个等级的详细数据
const getLevelData = (level: number) => {
if (level === 0) {
return {
cost: { metal: 0, crystal: 0, deuterium: 0 },
buildTime: 0,
production: 0,
consumption: 0,
points: 0
}
}
const cost = buildingLogic.calculateBuildingCost(props.buildingType, level)
const buildTime = buildingLogic.calculateBuildingTime(props.buildingType, level)
// 计算产量和消耗
let production = 0
let consumption = 0
// 资源矿产量
if (props.buildingType === 'metalMine') {
production = Math.floor(30 * level * Math.pow(1.1, level))
} else if (props.buildingType === 'crystalMine') {
production = Math.floor(20 * level * Math.pow(1.1, level))
} else if (props.buildingType === 'deuteriumSynthesizer') {
production = Math.floor(10 * level * Math.pow(1.1, level))
}
// 能量产出
if (props.buildingType === 'solarPlant') {
production = Math.floor(20 * level * Math.pow(1.1, level))
}
// 能量消耗(矿场和合成器)
if (['metalMine', 'crystalMine', 'deuteriumSynthesizer'].includes(props.buildingType)) {
consumption = Math.floor(10 * level * Math.pow(1.1, level))
}
// 计算积分
const points = pointsLogic.calculateBuildingPoints(props.buildingType, level - 1, level)
return {
cost,
buildTime,
production,
consumption,
points
}
}
// 累积统计
const totalStats = computed(() => {
let metal = 0
let crystal = 0
let deuterium = 0
let points = 0
for (const level of levelRange.value) {
if (level === 0) continue
const data = getLevelData(level)
metal += data.cost.metal
crystal += data.cost.crystal
deuterium += data.cost.deuterium
points += data.points
}
return { metal, crystal, deuterium, points }
})
const formatNumber = (num: number): string => {
return num.toLocaleString()
}
const formatTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}${t('common.timeSecond')}`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
if (minutes < 60) return `${minutes}${t('common.timeMinute')}${secs}${t('common.timeSecond')}`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}${t('common.timeHour')}${mins}${t('common.timeMinute')}`
}
</script>

View File

@@ -0,0 +1,168 @@
<template>
<div class="space-y-4">
<!-- 防御基础信息 -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Sword class="h-4 w-4" />
{{ t('defense.attack') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.attack) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Shield class="h-4 w-4" />
{{ t('defense.shield') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.shield) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<ShieldCheck class="h-4 w-4" />
{{ t('defense.armor') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.armor) }}</div>
</CardContent>
</Card>
</div>
<!-- 建造成本和时间 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle class="text-sm">{{ t('defense.buildCost') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div v-if="config.cost.metal > 0" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(config.cost.metal) }}</span>
</div>
<div v-if="config.cost.crystal > 0" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(config.cost.crystal) }}</span>
</div>
<div v-if="config.cost.deuterium > 0" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.deuterium') }}:</span>
<span class="font-medium">{{ formatNumber(config.cost.deuterium) }}</span>
</div>
<div class="flex items-center justify-between text-sm pt-2 border-t">
<span class="text-muted-foreground">{{ t('player.points') }}:</span>
<span class="font-bold text-primary">{{ pointsPerUnit }}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-sm">{{ t('defense.buildTime') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{{ formatTime(config.buildTime) }}</div>
<p class="text-xs text-muted-foreground mt-2">{{ t('defense.perUnit') }}</p>
</CardContent>
</Card>
</div>
<!-- 批量建造计算器 -->
<Card>
<CardHeader>
<CardTitle class="text-sm">{{ t('defense.batchCalculator') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-4">
<Label class="w-20">{{ t('defense.quantity') }}:</Label>
<Input v-model.number="quantity" type="number" min="1" class="flex-1" />
</div>
<div class="grid grid-cols-2 gap-4 pt-4 border-t">
<div class="space-y-2">
<p class="text-sm text-muted-foreground">{{ t('defense.totalCost') }}:</p>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span>{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(batchCost.metal) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(batchCost.crystal) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('resources.deuterium') }}:</span>
<span class="font-medium">{{ formatNumber(batchCost.deuterium) }}</span>
</div>
</div>
</div>
<div class="space-y-2">
<p class="text-sm text-muted-foreground">{{ t('defense.totalTime') }}:</p>
<div class="text-xl font-bold">{{ formatTime(config.buildTime * quantity) }}</div>
<p class="text-xs text-muted-foreground">{{ t('player.points') }}: +{{ formatNumber(batchPoints) }}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import type { DefenseType } from '@/types/game'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Sword, Shield, ShieldCheck } from 'lucide-vue-next'
import * as pointsLogic from '@/logic/pointsLogic'
import { DEFENSES } from '@/config/gameConfig'
const { t } = useI18n()
const props = defineProps<{
defenseType: DefenseType
}>()
const config = computed(() => DEFENSES[props.defenseType])
const quantity = ref(1)
// 单个防御的积分
const pointsPerUnit = computed(() => {
return pointsLogic.calculateDefensePoints(props.defenseType, 1)
})
// 批量建造成本
const batchCost = computed(() => ({
metal: config.value.cost.metal * quantity.value,
crystal: config.value.cost.crystal * quantity.value,
deuterium: config.value.cost.deuterium * quantity.value
}))
// 批量建造积分
const batchPoints = computed(() => {
return pointsLogic.calculateDefensePoints(props.defenseType, quantity.value)
})
const formatNumber = (num: number): string => {
return num.toLocaleString()
}
const formatTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}${t('common.timeSecond')}`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
if (minutes < 60) return `${minutes}${t('common.timeMinute')}${secs}${t('common.timeSecond')}`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}${t('common.timeHour')}${mins}${t('common.timeMinute')}`
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<div class="space-y-4">
<!-- 舰船基础信息 -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Sword class="h-4 w-4" />
{{ t('shipyard.attack') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.attack) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Shield class="h-4 w-4" />
{{ t('shipyard.shield') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.shield) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<ShieldCheck class="h-4 w-4" />
{{ t('shipyard.armor') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.armor) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Zap class="h-4 w-4" />
{{ t('shipyard.speed') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.speed) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Package class="h-4 w-4" />
{{ t('shipyard.cargoCapacity') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.cargoCapacity) }}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm flex items-center gap-2">
<Fuel class="h-4 w-4" />
{{ t('shipyard.fuelConsumption') }}
</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ formatNumber(config.fuelConsumption) }}</div>
</CardContent>
</Card>
</div>
<!-- 建造成本和时间 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle class="text-sm">{{ t('shipyard.buildCost') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div v-if="config.cost.metal > 0" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(config.cost.metal) }}</span>
</div>
<div v-if="config.cost.crystal > 0" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(config.cost.crystal) }}</span>
</div>
<div v-if="config.cost.deuterium > 0" class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.deuterium') }}:</span>
<span class="font-medium">{{ formatNumber(config.cost.deuterium) }}</span>
</div>
<div class="flex items-center justify-between text-sm pt-2 border-t">
<span class="text-muted-foreground">{{ t('player.points') }}:</span>
<span class="font-bold text-primary">{{ pointsPerUnit }}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="text-sm">{{ t('shipyard.buildTime') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold">{{ formatTime(config.buildTime) }}</div>
<p class="text-xs text-muted-foreground mt-2">{{ t('shipyard.perUnit') }}</p>
</CardContent>
</Card>
</div>
<!-- 批量建造计算器 -->
<Card>
<CardHeader>
<CardTitle class="text-sm">{{ t('shipyard.batchCalculator') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-4">
<Label class="w-20">{{ t('shipyard.quantity') }}:</Label>
<Input v-model.number="quantity" type="number" min="1" class="flex-1" />
</div>
<div class="grid grid-cols-2 gap-4 pt-4 border-t">
<div class="space-y-2">
<p class="text-sm text-muted-foreground">{{ t('shipyard.totalCost') }}:</p>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span>{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(batchCost.metal) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(batchCost.crystal) }}</span>
</div>
<div class="flex justify-between">
<span>{{ t('resources.deuterium') }}:</span>
<span class="font-medium">{{ formatNumber(batchCost.deuterium) }}</span>
</div>
</div>
</div>
<div class="space-y-2">
<p class="text-sm text-muted-foreground">{{ t('shipyard.totalTime') }}:</p>
<div class="text-xl font-bold">{{ formatTime(config.buildTime * quantity) }}</div>
<p class="text-xs text-muted-foreground">{{ t('player.points') }}: +{{ formatNumber(batchPoints) }}</p>
</div>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import type { ShipType } from '@/types/game'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Sword, Shield, ShieldCheck, Zap, Package, Fuel } from 'lucide-vue-next'
import * as pointsLogic from '@/logic/pointsLogic'
import { SHIPS } from '@/config/gameConfig'
const { t } = useI18n()
const props = defineProps<{
shipType: ShipType
}>()
const config = computed(() => SHIPS[props.shipType])
const quantity = ref(1)
// 单艘舰船的积分
const pointsPerUnit = computed(() => {
return pointsLogic.calculateShipPoints(props.shipType, 1)
})
// 批量建造成本
const batchCost = computed(() => ({
metal: config.value.cost.metal * quantity.value,
crystal: config.value.cost.crystal * quantity.value,
deuterium: config.value.cost.deuterium * quantity.value
}))
// 批量建造积分
const batchPoints = computed(() => {
return pointsLogic.calculateShipPoints(props.shipType, quantity.value)
})
const formatNumber = (num: number): string => {
return num.toLocaleString()
}
const formatTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}${t('common.timeSecond')}`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
if (minutes < 60) return `${minutes}${t('common.timeMinute')}${secs}${t('common.timeSecond')}`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}${t('common.timeHour')}${mins}${t('common.timeMinute')}`
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<div class="space-y-4">
<!-- 科技等级范围表格 -->
<div class="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-20 text-center">{{ t('research.levelRange') }}</TableHead>
<TableHead class="text-center">{{ t('resources.metal') }}</TableHead>
<TableHead class="text-center">{{ t('resources.crystal') }}</TableHead>
<TableHead class="text-center">{{ t('resources.deuterium') }}</TableHead>
<TableHead class="text-center">{{ t('research.researchTime') }}</TableHead>
<TableHead class="text-center">{{ t('player.points') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="level in levelRange" :key="level" :class="{ 'bg-muted/50': level === currentLevel }">
<TableCell class="text-center font-medium">
<Badge v-if="level === currentLevel" variant="default">{{ level }}</Badge>
<span v-else>{{ level }}</span>
</TableCell>
<TableCell class="text-center text-sm">{{ formatNumber(getLevelData(level).cost.metal) }}</TableCell>
<TableCell class="text-center text-sm">{{ formatNumber(getLevelData(level).cost.crystal) }}</TableCell>
<TableCell class="text-center text-sm">{{ formatNumber(getLevelData(level).cost.deuterium) }}</TableCell>
<TableCell class="text-center text-sm">{{ formatTime(getLevelData(level).researchTime) }}</TableCell>
<TableCell class="text-center text-sm">
<span class="text-primary font-medium">+{{ getLevelData(level).points }}</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 累积统计 -->
<div class="grid grid-cols-2 gap-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm">{{ t('research.totalCost') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(totalStats.metal) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(totalStats.crystal) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('resources.deuterium') }}:</span>
<span class="font-medium">{{ formatNumber(totalStats.deuterium) }}</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="text-sm">{{ t('research.totalPoints') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="text-3xl font-bold text-primary">{{ formatNumber(totalStats.points) }}</div>
<p class="text-xs text-muted-foreground mt-1">
{{ t('research.levelRange') }}: {{ Math.max(0, currentLevel - 10) }} - {{ Math.min(currentLevel + 10, currentLevel + 10) }}
</p>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import type { TechnologyType } from '@/types/game'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import * as researchLogic from '@/logic/researchLogic'
import * as pointsLogic from '@/logic/pointsLogic'
const { t } = useI18n()
const props = defineProps<{
technologyType: TechnologyType
currentLevel: number
}>()
// 等级范围:当前等级 ±10
const levelRange = computed(() => {
const start = Math.max(0, props.currentLevel - 10)
const end = props.currentLevel + 10
const levels = []
for (let i = start; i <= end; i++) {
levels.push(i)
}
return levels
})
// 获取某个等级的详细数据
const getLevelData = (level: number) => {
if (level === 0) {
return {
cost: { metal: 0, crystal: 0, deuterium: 0 },
researchTime: 0,
points: 0
}
}
const cost = researchLogic.calculateTechnologyCost(props.technologyType, level)
const researchTime = researchLogic.calculateTechnologyTime(props.technologyType, level - 1)
// 计算积分
const points = pointsLogic.calculateTechnologyPoints(props.technologyType, level - 1, level)
return {
cost,
researchTime,
points
}
}
// 累积统计
const totalStats = computed(() => {
let metal = 0
let crystal = 0
let deuterium = 0
let points = 0
for (const level of levelRange.value) {
if (level === 0) continue
const data = getLevelData(level)
metal += data.cost.metal
crystal += data.cost.crystal
deuterium += data.cost.deuterium
points += data.points
}
return { metal, crystal, deuterium, points }
})
const formatNumber = (num: number): string => {
return num.toLocaleString()
}
const formatTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}${t('common.timeSecond')}`
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
if (minutes < 60) return `${minutes}${t('common.timeMinute')}${secs}${t('common.timeSecond')}`
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
return `${hours}${t('common.timeHour')}${mins}${t('common.timeMinute')}`
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</AlertDialogRoot>
</template>
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from 'reka-ui'
import { AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>

View File

@@ -0,0 +1,18 @@
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
<script setup lang="ts">
import type { AlertDialogActionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogAction } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,18 @@
<template>
<AlertDialogCancel v-bind="delegatedProps" :class="cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)">
<slot />
</AlertDialogCancel>
</template>
<script setup lang="ts">
import type { AlertDialogCancelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogCancel } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,39 @@
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogContent, AlertDialogOverlay, AlertDialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogDescription } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="alert-dialog-footer" :class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="alert-dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<AlertDialogTitle data-slot="alert-dialog-title" v-bind="delegatedProps" :class="cn('text-lg font-semibold', props.class)">
<slot />
</AlertDialogTitle>
</template>
<script setup lang="ts">
import type { AlertDialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { AlertDialogTitle } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,12 @@
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>
<script setup lang="ts">
import type { AlertDialogTriggerProps } from 'reka-ui'
import { AlertDialogTrigger } from 'reka-ui'
const props = defineProps<AlertDialogTriggerProps>()
</script>

View File

@@ -0,0 +1,9 @@
export { default as AlertDialog } from './AlertDialog.vue'
export { default as AlertDialogAction } from './AlertDialogAction.vue'
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
export { default as AlertDialogContent } from './AlertDialogContent.vue'
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'

View File

@@ -0,0 +1,24 @@
<template>
<Primitive data-slot="badge" :class="cn(badgeVariants({ variant }), props.class)" v-bind="delegatedProps">
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '.'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { badgeVariants } from '.'
const props = defineProps<
PrimitiveProps & {
variant?: BadgeVariants['variant']
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,23 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Badge } from './Badge.vue'
export const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@@ -0,0 +1,24 @@
<template>
<Primitive data-slot="button" :as="as" :as-child="asChild" :class="cn(buttonVariants({ variant, size }), props.class)">
<slot />
</Primitive>
</template>
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
as: 'button'
})
</script>

View File

@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Button } from './Button.vue'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="card" :class="cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="card-action" :class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<p data-slot="card-description" :class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</p>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="card-footer" :class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class
)
"
>
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<h3 data-slot="card-title" :class="cn('leading-none font-semibold', props.class)">
<slot />
</h3>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,7 @@
export { default as Card } from './Card.vue'
export { default as CardAction } from './CardAction.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardFooter } from './CardFooter.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'

View File

@@ -0,0 +1,15 @@
<template>
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>

View File

@@ -0,0 +1,49 @@
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class
)
"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import DialogOverlay from './DialogOverlay.vue'
defineOptions({
inheritAttrs: false
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class']; showCloseButton?: boolean }>(), {
showCloseButton: true
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

View File

@@ -0,0 +1,19 @@
<template>
<DialogDescription data-slot="dialog-description" v-bind="forwardedProps" :class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</DialogDescription>
</template>
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div data-slot="dialog-footer" :class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div data-slot="dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,26 @@
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class
)
"
>
<slot />
</DialogOverlay>
</template>
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,51 @@
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

View File

@@ -0,0 +1,19 @@
<template>
<DialogTitle data-slot="dialog-title" v-bind="forwardedProps" :class="cn('text-lg leading-none font-semibold', props.class)">
<slot />
</DialogTitle>
</template>
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>

View File

@@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'

View File

@@ -0,0 +1,35 @@
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class
)
"
/>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes['class']
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue
})
</script>

View File

@@ -0,0 +1 @@
export { default as Input } from './Input.vue'

View File

@@ -0,0 +1,26 @@
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class
)
"
>
<slot />
</Label>
</template>
<script setup lang="ts">
import type { LabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<LabelProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1 @@
export { default as Label } from './Label.vue'

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot
v-slot="slotProps"
data-slot="popover"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</PopoverRoot>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from "reka-ui"
import { PopoverAnchor } from "reka-ui"
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor
data-slot="popover-anchor"
v-bind="props"
>
<slot />
</PopoverAnchor>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
PopoverContent,
PopoverPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
{
align: "center",
sideOffset: 4,
},
)
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from "reka-ui"
import { PopoverTrigger } from "reka-ui"
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger
data-slot="popover-trigger"
v-bind="props"
>
<slot />
</PopoverTrigger>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Popover } from "./Popover.vue"
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
export { default as PopoverContent } from "./PopoverContent.vue"
export { default as PopoverTrigger } from "./PopoverTrigger.vue"

View File

@@ -0,0 +1,27 @@
<template>
<ProgressRoot
data-slot="progress"
v-bind="delegatedProps"
:class="cn('bg-primary/20 relative h-2 w-full overflow-hidden rounded-full', props.class)"
>
<ProgressIndicator
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
:style="`transform: translateX(-${100 - (props.modelValue ?? 0)}%);`"
/>
</ProgressRoot>
</template>
<script setup lang="ts">
import type { ProgressRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ProgressIndicator, ProgressRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<ProgressRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: 0
})
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1 @@
export { default as Progress } from './Progress.vue'

View File

@@ -0,0 +1,15 @@
<template>
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
<slot v-bind="slotProps" />
</SelectRoot>
</template>
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from 'reka-ui'
import { SelectRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>

View File

@@ -0,0 +1,51 @@
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({
inheritAttrs: false
})
const props = withDefaults(defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(), {
position: 'popper'
})
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<SelectGroup data-slot="select-group" v-bind="props">
<slot />
</SelectGroup>
</template>
<script setup lang="ts">
import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
const props = defineProps<SelectGroupProps>()
</script>

View File

@@ -0,0 +1,39 @@
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>
<script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from 'lucide-vue-next'
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<SelectItemText data-slot="select-item-text" v-bind="props">
<slot />
</SelectItemText>
</template>
<script setup lang="ts">
import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<SelectLabel data-slot="select-label" :class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)">
<slot />
</SelectLabel>
</template>
<script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,26 @@
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>
<script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from 'lucide-vue-next'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>

View File

@@ -0,0 +1,26 @@
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>
<script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronUp } from 'lucide-vue-next'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>

View File

@@ -0,0 +1,19 @@
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>
<script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,34 @@
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="
cn(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>
<script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from 'lucide-vue-next'
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(defineProps<SelectTriggerProps & { class?: HTMLAttributes['class']; size?: 'sm' | 'default' }>(), {
size: 'default'
})
const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwardedProps = useForwardProps(delegatedProps)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<SelectValue data-slot="select-value" v-bind="props">
<slot />
</SelectValue>
</template>
<script setup lang="ts">
import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>()
</script>

View File

@@ -0,0 +1,11 @@
export { default as Select } from './Select.vue'
export { default as SelectContent } from './SelectContent.vue'
export { default as SelectGroup } from './SelectGroup.vue'
export { default as SelectItem } from './SelectItem.vue'
export { default as SelectItemText } from './SelectItemText.vue'
export { default as SelectLabel } from './SelectLabel.vue'
export { default as SelectScrollDownButton } from './SelectScrollDownButton.vue'
export { default as SelectScrollUpButton } from './SelectScrollUpButton.vue'
export { default as SelectSeparator } from './SelectSeparator.vue'
export { default as SelectTrigger } from './SelectTrigger.vue'
export { default as SelectValue } from './SelectValue.vue'

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
import type { ToasterProps } from "vue-sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
import { Toaster as Sonner } from "vue-sonner"
import { cn } from "@/lib/utils"
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./Sonner.vue"

View File

@@ -0,0 +1,16 @@
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<caption data-slot="table-caption" :class="cn('text-muted-foreground mt-4 text-sm', props.class)">
<slot />
</caption>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,17 @@
<template>
<td
data-slot="table-cell"
:class="cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</td>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,29 @@
<template>
<TableRow>
<TableCell :class="cn('p-4 whitespace-nowrap align-middle text-sm text-foreground', props.class)" v-bind="delegatedProps">
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { cn } from '@/lib/utils'
import TableCell from './TableCell.vue'
import TableRow from './TableRow.vue'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
colspan?: number
}>(),
{
colspan: 1
}
)
const delegatedProps = reactiveOmit(props, 'class')
</script>

View File

@@ -0,0 +1,14 @@
<template>
<tfoot data-slot="table-footer" :class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)">
<slot />
</tfoot>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<th
data-slot="table-head"
:class="
cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class
)
"
>
<slot />
</th>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,14 @@
<template>
<tr data-slot="table-row" :class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)">
<slot />
</tr>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>

View File

@@ -0,0 +1,9 @@
export { default as Table } from './Table.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableCaption } from './TableCaption.vue'
export { default as TableCell } from './TableCell.vue'
export { default as TableEmpty } from './TableEmpty.vue'
export { default as TableFooter } from './TableFooter.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableRow } from './TableRow.vue'

View File

@@ -0,0 +1,8 @@
import type { Updater } from '@tanstack/vue-table'
import type { Ref } from 'vue'
import { isFunction } from '@tanstack/vue-table'
export const valueUpdater = <T>(updaterOrValue: Updater<T>, ref: Ref<T>) => {
ref.value = isFunction(updaterOrValue) ? updaterOrValue(ref.value) : updaterOrValue
}