mirror of
https://github.com/setube/ogame-vue-ts.git
synced 2026-05-12 07:55:11 +08:00
feat: 新增NPC与外交逻辑,优化UI组件结构
重构并精简了部分UI组件,移除冗余弹窗与详情组件,新增NPC相关逻辑(npcBehaviorLogic、npcGrowthLogic、npcStore等)及外交逻辑(diplomaticLogic、DiplomacyView)。完善分页、标签、复选框等通用UI组件。优化战报弹窗,调整README下载链接为相对路径,修复部分国际化内容。
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
<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>
|
||||
@@ -1,15 +1,18 @@
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Trophy class="h-5 w-5" />
|
||||
{{ t('messagesView.battleReport') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="report">
|
||||
{{ formatDate(report.timestamp) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollableDialogContent container-class="sm:max-w-4xl max-h-[90vh]">
|
||||
<template #header>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Trophy class="h-5 w-5" />
|
||||
{{ t('messagesView.battleReport') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="report">
|
||||
{{ formatDate(report.timestamp) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</template>
|
||||
|
||||
<div v-if="report" class="space-y-4">
|
||||
<!-- 战斗双方信息 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
@@ -260,7 +263,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</ScrollableDialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -270,7 +273,7 @@
|
||||
import { useUniverseStore } from '@/stores/universeStore'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { useGameConfig } from '@/composables/useGameConfig'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Dialog, ScrollableDialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import ResourceIcon from '@/components/ResourceIcon.vue'
|
||||
|
||||
@@ -13,7 +13,19 @@
|
||||
</div>
|
||||
|
||||
<!-- 前置条件详情对话框 -->
|
||||
<AlertDialog ref="requirementsDialog" />
|
||||
<AlertDialog :open="requirementsDialogOpen" @update:open="requirementsDialogOpen = $event">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ requirementsDialogTitle }}</AlertDialogTitle>
|
||||
<AlertDialogDescription class="whitespace-pre-line">
|
||||
{{ requirementsDialogMessage }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +37,15 @@
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import * as publicLogic from '@/logic/publicLogic'
|
||||
|
||||
interface Props {
|
||||
@@ -37,7 +57,11 @@
|
||||
const gameStore = useGameStore()
|
||||
const { t } = useI18n()
|
||||
const { BUILDINGS, TECHNOLOGIES } = useGameConfig()
|
||||
const requirementsDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
|
||||
|
||||
// AlertDialog 状态
|
||||
const requirementsDialogOpen = ref(false)
|
||||
const requirementsDialogTitle = ref('')
|
||||
const requirementsDialogMessage = ref('')
|
||||
|
||||
const isUnlocked = computed(() => {
|
||||
// 如果已经建造过(level > 0),则认为已解锁,不显示遮罩
|
||||
@@ -72,9 +96,8 @@
|
||||
}
|
||||
|
||||
const showRequirements = () => {
|
||||
requirementsDialog.value?.show({
|
||||
title: t('common.requirementsNotMet'),
|
||||
message: getRequirementsList()
|
||||
})
|
||||
requirementsDialogTitle.value = t('common.requirementsNotMet')
|
||||
requirementsDialogMessage.value = getRequirementsList()
|
||||
requirementsDialogOpen.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<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>
|
||||
@@ -1,81 +1,61 @@
|
||||
<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">
|
||||
<ScrollableDialogContent
|
||||
v-if="dialogStore.type && dialogStore.itemType"
|
||||
container-class="sm:max-w-[90vw] md:max-w-3xl lg:max-w-4xl max-h-[90vh]"
|
||||
>
|
||||
<template #header>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
{{ t(`buildings.${dialogStore.itemType}`) }}
|
||||
<Badge variant="outline">{{ t('common.currentLevel') }} {{ dialogStore.currentLevel || 0 }}</Badge>
|
||||
{{ itemTitle }}
|
||||
<Badge v-if="dialogStore.currentLevel !== undefined" variant="outline">
|
||||
{{ t('common.currentLevel') }} {{ dialogStore.currentLevel }}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t(`buildingDescriptions.${dialogStore.itemType}`) }}
|
||||
{{ itemDescription }}
|
||||
</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>
|
||||
<ItemDetailView :type="dialogStore.type" :itemType="dialogStore.itemType" :currentLevel="dialogStore.currentLevel" />
|
||||
</ScrollableDialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { computed } from 'vue'
|
||||
import { Dialog, ScrollableDialogContent, 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'
|
||||
import ItemDetailView from './ItemDetailView.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const dialogStore = useDetailDialogStore()
|
||||
|
||||
const itemTitle = computed(() => {
|
||||
if (!dialogStore.type || !dialogStore.itemType) return ''
|
||||
const typeMap = {
|
||||
building: 'buildings',
|
||||
technology: 'technologies',
|
||||
ship: 'ships',
|
||||
defense: 'defenses'
|
||||
}
|
||||
return t(`${typeMap[dialogStore.type]}.${dialogStore.itemType}`)
|
||||
})
|
||||
|
||||
const itemDescription = computed(() => {
|
||||
if (!dialogStore.type || !dialogStore.itemType) return ''
|
||||
const typeMap = {
|
||||
building: 'buildingDescriptions',
|
||||
technology: 'technologyDescriptions',
|
||||
ship: 'shipDescriptions',
|
||||
defense: 'defenseDescriptions'
|
||||
}
|
||||
return t(`${typeMap[dialogStore.type]}.${dialogStore.itemType}`)
|
||||
})
|
||||
|
||||
const handleClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
dialogStore.close()
|
||||
|
||||
101
src/components/IncomingFleetAlerts.vue
Normal file
101
src/components/IncomingFleetAlerts.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div v-if="alerts.length > 0" class="bg-destructive/10 border-b border-destructive/20">
|
||||
<div class="px-4 sm:px-6 py-2 space-y-2">
|
||||
<div
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="flex items-center justify-between gap-3 bg-destructive/5 rounded-lg px-3 py-2 border border-destructive/20"
|
||||
>
|
||||
<!-- 警告图标和信息 -->
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 animate-pulse" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-destructive truncate">
|
||||
<template v-if="alert.missionType === 'spy'">
|
||||
{{ t('alerts.npcSpyIncoming') }}
|
||||
</template>
|
||||
<template v-else-if="alert.missionType === 'attack'">
|
||||
{{ t('alerts.npcAttackIncoming') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('alerts.npcFleetIncoming') }}
|
||||
</template>
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate">
|
||||
{{ alert.npcName }} → {{ alert.targetPlanetName }}
|
||||
<template v-if="alert.missionType === 'attack'">({{ alert.fleetSize }} {{ t('alerts.ships') }})</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 倒计时 -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="text-right">
|
||||
<p class="text-xs font-mono text-destructive">
|
||||
{{ formatTimeRemaining(alert.arrivalTime) }}
|
||||
</p>
|
||||
<p class="text-[10px] text-muted-foreground">
|
||||
{{ formatTime(alert.arrivalTime) }}
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="markAsRead(alert)" variant="ghost" size="sm" class="h-6 w-6 p-0">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import type { IncomingFleetAlert } from '@/types/game'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, X } from 'lucide-vue-next'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
|
||||
const props = defineProps<{
|
||||
alerts: IncomingFleetAlert[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'markAsRead', alert: IncomingFleetAlert): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// 强制更新倒计时
|
||||
const now = ref(Date.now())
|
||||
let updateInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
updateInterval = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (updateInterval) clearInterval(updateInterval)
|
||||
})
|
||||
|
||||
const formatTimeRemaining = (arrivalTime: number): string => {
|
||||
const remaining = Math.max(0, arrivalTime - now.value)
|
||||
const seconds = Math.floor((remaining / 1000) % 60)
|
||||
const minutes = Math.floor((remaining / (1000 * 60)) % 60)
|
||||
const hours = Math.floor(remaining / (1000 * 60 * 60))
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number): string => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const markAsRead = (alert: IncomingFleetAlert) => {
|
||||
emit('markAsRead', alert)
|
||||
}
|
||||
</script>
|
||||
733
src/components/ItemDetailView.vue
Normal file
733
src/components/ItemDetailView.vue
Normal file
@@ -0,0 +1,733 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 建筑/科技:等级范围表格 -->
|
||||
<div v-if="type === 'building' || type === 'technology'" class="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-20 text-center">{{ t(`${typeKey}.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 v-if="showDarkMatterColumn" class="text-center">{{ t('resources.darkMatter') }}</TableHead>
|
||||
<TableHead class="text-center">{{ type === 'building' ? t('buildings.buildTime') : t('research.researchTime') }}</TableHead>
|
||||
<!-- 建筑相关列 -->
|
||||
<TableHead v-if="type === 'building' && showProductionColumn" class="text-center">{{ t('buildings.production') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showConsumptionColumn" class="text-center">{{ t('buildings.consumption') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showCapacityColumn" class="text-center">{{ t('buildings.storageCapacity') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showFleetStorageColumn" class="text-center">{{ t('buildings.fleetStorage') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showBuildQueueColumn" class="text-center">{{ t('buildings.buildQueueBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showSpaceColumn" class="text-center">{{ t('buildings.spaceBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showMissileColumn" class="text-center">{{ t('buildings.missileCapacity') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showBuildSpeedColumn" class="text-center">{{ t('buildings.buildSpeedBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'building' && showResearchSpeedColumn" class="text-center">{{ t('buildings.researchSpeedBonus') }}</TableHead>
|
||||
<!-- 科技相关列 -->
|
||||
<TableHead v-if="type === 'technology' && showAttackBonusColumn" class="text-center">{{ t('research.attackBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showShieldBonusColumn" class="text-center">{{ t('research.shieldBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showArmorBonusColumn" class="text-center">{{ t('research.armorBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showSpyLevelColumn" class="text-center">{{ t('research.spyLevel') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showFleetStorageColumn" class="text-center">{{ t('buildings.fleetStorage') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showResearchQueueColumn" class="text-center">{{ t('research.researchQueueBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showColonySlotsColumn" class="text-center">{{ t('research.colonySlots') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showSpaceColumn" class="text-center">{{ t('buildings.spaceBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showSpeedBonusColumn" class="text-center">{{ t('research.speedBonus') }}</TableHead>
|
||||
<TableHead v-if="type === 'technology' && showResearchSpeedColumn" class="text-center">{{ t('buildings.researchSpeedBonus') }}</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 === safeCurrentLevel }">
|
||||
<TableCell class="text-center font-medium">
|
||||
<Badge v-if="level === safeCurrentLevel" variant="default">{{ level }}</Badge>
|
||||
<span v-else>{{ level }}</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.metal" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.crystal" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.deuterium" />
|
||||
</TableCell>
|
||||
<TableCell v-if="showDarkMatterColumn" class="text-center text-sm">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.darkMatter" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">{{ formatTime(getLevelData(level).time) }}</TableCell>
|
||||
<!-- 建筑相关数据 -->
|
||||
<TableCell v-if="type === 'building' && showProductionColumn" class="text-center text-sm">
|
||||
<span v-if="getLevelData(level).production > 0" class="text-green-600 dark:text-green-400">
|
||||
+
|
||||
<NumberWithTooltip :value="getLevelData(level).production" />
|
||||
/{{ t('resources.perHour') }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showConsumptionColumn" class="text-center text-sm">
|
||||
<span v-if="getLevelData(level).consumption > 0" class="text-red-600 dark:text-red-400">
|
||||
-
|
||||
<NumberWithTooltip :value="getLevelData(level).consumption" />
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showCapacityColumn" class="text-center text-sm">
|
||||
<span v-if="getLevelData(level).capacity > 0" class="text-blue-600 dark:text-blue-400">
|
||||
<NumberWithTooltip :value="getLevelData(level).capacity" />
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showFleetStorageColumn" class="text-center text-sm">
|
||||
<span v-if="getLevelData(level).fleetStorage > 0" class="text-blue-600 dark:text-blue-400">
|
||||
+<NumberWithTooltip :value="getLevelData(level).fleetStorage" />
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showBuildQueueColumn" class="text-center text-sm">
|
||||
<span class="text-purple-600 dark:text-purple-400">+1</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showSpaceColumn" class="text-center text-sm">
|
||||
<span v-if="getLevelData(level).spaceBonus > 0" class="text-green-600 dark:text-green-400">
|
||||
+<NumberWithTooltip :value="getLevelData(level).spaceBonus" />
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showMissileColumn" class="text-center text-sm">
|
||||
<span class="text-orange-600 dark:text-orange-400">+10</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showBuildSpeedColumn" class="text-center text-sm">
|
||||
<span v-if="itemType === 'roboticsFactory'" class="text-cyan-600 dark:text-cyan-400">+{{ getLevelData(level).buildSpeedBonus * 100 }}%</span>
|
||||
<span v-else-if="itemType === 'naniteFactory'" class="text-cyan-600 dark:text-cyan-400">+{{ getLevelData(level).buildSpeedBonus * 100 }}%</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'building' && showResearchSpeedColumn" class="text-center text-sm">
|
||||
<span class="text-indigo-600 dark:text-indigo-400">+{{ (getLevelData(level).researchSpeedBonus - 1) * 100 }}%</span>
|
||||
</TableCell>
|
||||
<!-- 科技相关数据 -->
|
||||
<TableCell v-if="type === 'technology' && showAttackBonusColumn" class="text-center text-sm">
|
||||
<span class="text-red-600 dark:text-red-400">+{{ level * 10 }}%</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showShieldBonusColumn" class="text-center text-sm">
|
||||
<span class="text-blue-600 dark:text-blue-400">+{{ level * 10 }}%</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showArmorBonusColumn" class="text-center text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">+{{ level * 10 }}%</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showSpyLevelColumn" class="text-center text-sm">
|
||||
<span class="text-purple-600 dark:text-purple-400">+{{ level }}</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showFleetStorageColumn" class="text-center text-sm">
|
||||
<span class="text-blue-600 dark:text-blue-400">+<NumberWithTooltip :value="level * 500" /></span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showResearchQueueColumn" class="text-center text-sm">
|
||||
<span class="text-purple-600 dark:text-purple-400">+1</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showColonySlotsColumn" class="text-center text-sm">
|
||||
<span class="text-green-600 dark:text-green-400">+1</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showSpaceColumn" class="text-center text-sm">
|
||||
<span class="text-green-600 dark:text-green-400">+5 {{ t('research.forAllPlanets') }}</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showSpeedBonusColumn" class="text-center text-sm">
|
||||
<span class="text-yellow-600 dark:text-yellow-400">+{{ level * 10 }}%</span>
|
||||
</TableCell>
|
||||
<TableCell v-if="type === 'technology' && showResearchSpeedColumn" class="text-center text-sm">
|
||||
<span class="text-indigo-600 dark:text-indigo-400">+{{ getLevelData(level).researchSpeedBonus * 100 }}%</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<span class="text-primary font-medium">
|
||||
+
|
||||
<NumberWithTooltip :value="getLevelData(level).points" />
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- 建筑/科技:累积统计 -->
|
||||
<div v-if="type === 'building' || type === 'technology'" class="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-sm">{{ t(`${typeKey}.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"><NumberWithTooltip :value="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"><NumberWithTooltip :value="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"><NumberWithTooltip :value="totalStats.deuterium" /></span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-sm">{{ t(`${typeKey}.totalPoints`) }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold text-primary">
|
||||
<NumberWithTooltip :value="totalStats.points" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ t(`${typeKey}.levelRange`) }}: {{ Math.max(0, safeCurrentLevel - 10) }} - {{ safeCurrentLevel + 10 }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 舰船/防御:基础属性 -->
|
||||
<div v-if="type === 'ship' || type === 'defense'" 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(`${typeKey}.attack`) }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
<NumberWithTooltip :value="combatUnitConfig?.attack || 0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-sm flex items-center gap-2">
|
||||
<Shield class="h-4 w-4" />
|
||||
{{ t(`${typeKey}.shield`) }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
<NumberWithTooltip :value="combatUnitConfig?.shield || 0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="pb-3">
|
||||
<CardTitle class="text-sm flex items-center gap-2">
|
||||
<ShieldCheck class="h-4 w-4" />
|
||||
{{ t(`${typeKey}.armor`) }}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-2xl font-bold">
|
||||
<NumberWithTooltip :value="combatUnitConfig?.armor || 0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 仅舰船显示 -->
|
||||
<Card v-if="type === 'ship'">
|
||||
<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">
|
||||
<NumberWithTooltip :value="shipConfig?.speed || 0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="type === 'ship'">
|
||||
<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">
|
||||
<NumberWithTooltip :value="shipConfig?.cargoCapacity || 0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card v-if="type === 'ship'">
|
||||
<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">
|
||||
<NumberWithTooltip :value="shipConfig?.fuelConsumption || 0" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 舰船/防御:建造成本和时间 -->
|
||||
<div v-if="type === 'ship' || type === 'defense'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">{{ t(`${typeKey}.buildCost`) }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<div v-for="resourceType in costResourceTypes" :key="resourceType.key" v-show="unitCost[resourceType.key] > 0" class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t(`resources.${resourceType.key}`) }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="unitCost[resourceType.key]" /></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"><NumberWithTooltip :value="pointsPerUnit" /></span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">{{ t(`${typeKey}.buildTime`) }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="text-3xl font-bold">{{ formatTime(unitBuildTime) }}</div>
|
||||
<p class="text-xs text-muted-foreground mt-2">{{ t(`${typeKey}.perUnit`) }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 舰船/防御:批量建造计算器 -->
|
||||
<Card v-if="type === 'ship' || type === 'defense'">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-sm">{{ t(`${typeKey}.batchCalculator`) }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<Label class="w-20">{{ t(`${typeKey}.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(`${typeKey}.totalCost`) }}:</p>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div v-for="resourceType in costResourceTypes" :key="resourceType.key" class="flex justify-between">
|
||||
<span>{{ t(`resources.${resourceType.key}`) }}:</span>
|
||||
<span class="font-medium"><NumberWithTooltip :value="batchCost[resourceType.key]" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm text-muted-foreground">{{ t(`${typeKey}.totalTime`) }}:</p>
|
||||
<div class="text-xl font-bold">{{ formatTime(unitBuildTime * quantity) }}</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t('player.points') }}: +
|
||||
<NumberWithTooltip :value="batchPoints" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import type { BuildingType, TechnologyType, ShipType, DefenseType } 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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import NumberWithTooltip from '@/components/NumberWithTooltip.vue'
|
||||
import { Sword, Shield, ShieldCheck, Zap, Package, Fuel } from 'lucide-vue-next'
|
||||
import * as buildingLogic from '@/logic/buildingLogic'
|
||||
import * as researchLogic from '@/logic/researchLogic'
|
||||
import * as pointsLogic from '@/logic/pointsLogic'
|
||||
import * as officerLogic from '@/logic/officerLogic'
|
||||
import * as shipLogic from '@/logic/shipLogic'
|
||||
import { SHIPS, DEFENSES } from '@/config/gameConfig'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gameStore = useGameStore()
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'building' | 'technology' | 'ship' | 'defense'
|
||||
itemType: BuildingType | TechnologyType | ShipType | DefenseType
|
||||
currentLevel?: number
|
||||
}>()
|
||||
|
||||
const quantity = ref(1)
|
||||
|
||||
// 资源类型配置(用于成本显示)
|
||||
const costResourceTypes = [{ key: 'metal' as const }, { key: 'crystal' as const }, { key: 'deuterium' as const }]
|
||||
|
||||
// 获取当前星球
|
||||
const currentPlanet = computed(() => gameStore.currentPlanet)
|
||||
|
||||
// 计算当前加成
|
||||
const activeBonuses = computed(() => {
|
||||
return officerLogic.calculateActiveBonuses(gameStore.player.officers, gameStore.gameTime)
|
||||
})
|
||||
|
||||
// 获取工厂等级(用于建造时间计算)
|
||||
const roboticsFactoryLevel = computed(() => {
|
||||
if (!currentPlanet.value) return 0
|
||||
return currentPlanet.value.buildings['roboticsFactory'] || 0
|
||||
})
|
||||
|
||||
const naniteFactoryLevel = computed(() => {
|
||||
if (!currentPlanet.value) return 0
|
||||
return currentPlanet.value.buildings['naniteFactory'] || 0
|
||||
})
|
||||
|
||||
// 获取研究所等级(用于研究时间计算)
|
||||
const researchLabLevel = computed(() => {
|
||||
if (!currentPlanet.value) return 0
|
||||
return currentPlanet.value.buildings['researchLab'] || 0
|
||||
})
|
||||
|
||||
// 翻译键(转换为复数形式)
|
||||
const typeKey = computed(() => {
|
||||
const typeMap = {
|
||||
building: 'buildings',
|
||||
technology: 'research',
|
||||
ship: 'shipyard',
|
||||
defense: 'defense'
|
||||
} as const
|
||||
return typeMap[props.type]
|
||||
})
|
||||
|
||||
// 控制建筑列显示
|
||||
const showDarkMatterColumn = computed(() => {
|
||||
if (props.type === 'building') {
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return buildingType === 'darkMatterCollector'
|
||||
} else if (props.type === 'technology') {
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'gravitonTechnology'
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const showProductionColumn = computed(() => {
|
||||
if (props.type !== 'building') return false
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return ['metalMine', 'crystalMine', 'deuteriumSynthesizer', 'solarPlant', 'fusionReactor', 'darkMatterCollector'].includes(buildingType)
|
||||
})
|
||||
|
||||
const showConsumptionColumn = computed(() => {
|
||||
if (props.type !== 'building') return false
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return ['metalMine', 'crystalMine', 'deuteriumSynthesizer'].includes(buildingType)
|
||||
})
|
||||
|
||||
const showCapacityColumn = computed(() => {
|
||||
if (props.type !== 'building') return false
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return ['metalStorage', 'crystalStorage', 'deuteriumTank', 'darkMatterCollector', 'darkMatterTank'].includes(buildingType)
|
||||
})
|
||||
|
||||
const showFleetStorageColumn = computed(() => {
|
||||
if (props.type === 'building') {
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return buildingType === 'shipyard'
|
||||
} else if (props.type === 'technology') {
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'computerTechnology'
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const showBuildQueueColumn = computed(() => {
|
||||
if (props.type !== 'building') return false
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return buildingType === 'naniteFactory'
|
||||
})
|
||||
|
||||
const showSpaceColumn = computed(() => {
|
||||
if (props.type === 'building') {
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return ['terraformer', 'lunarBase'].includes(buildingType)
|
||||
} else if (props.type === 'technology') {
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'terraformingTechnology'
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const showMissileColumn = computed(() => {
|
||||
if (props.type !== 'building') return false
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return buildingType === 'missileSilo'
|
||||
})
|
||||
|
||||
const showBuildSpeedColumn = computed(() => {
|
||||
if (props.type !== 'building') return false
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return ['roboticsFactory', 'naniteFactory'].includes(buildingType)
|
||||
})
|
||||
|
||||
const showResearchSpeedColumn = computed(() => {
|
||||
if (props.type === 'building') {
|
||||
const buildingType = props.itemType as BuildingType
|
||||
return buildingType === 'researchLab'
|
||||
} else if (props.type === 'technology') {
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'energyTechnology'
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// 控制科技列显示
|
||||
const showAttackBonusColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'weaponsTechnology'
|
||||
})
|
||||
|
||||
const showShieldBonusColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'shieldingTechnology'
|
||||
})
|
||||
|
||||
const showArmorBonusColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'armourTechnology'
|
||||
})
|
||||
|
||||
const showSpyLevelColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'espionageTechnology'
|
||||
})
|
||||
|
||||
const showResearchQueueColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'computerTechnology'
|
||||
})
|
||||
|
||||
const showColonySlotsColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return techType === 'astrophysics'
|
||||
})
|
||||
|
||||
const showSpeedBonusColumn = computed(() => {
|
||||
if (props.type !== 'technology') return false
|
||||
const techType = props.itemType as TechnologyType
|
||||
return ['combustionDrive', 'impulseDrive', 'hyperspaceDrive'].includes(techType)
|
||||
})
|
||||
|
||||
// 安全的当前等级(防止undefined)
|
||||
const safeCurrentLevel = computed(() => props.currentLevel ?? 0)
|
||||
|
||||
// 类型安全:战斗单位配置(舰船/防御)
|
||||
const combatUnitConfig = computed(() => {
|
||||
if (props.type === 'ship') return SHIPS[props.itemType as ShipType]
|
||||
if (props.type === 'defense') return DEFENSES[props.itemType as DefenseType]
|
||||
return null
|
||||
})
|
||||
|
||||
// 类型安全:舰船配置
|
||||
const shipConfig = computed(() => {
|
||||
if (props.type === 'ship') return SHIPS[props.itemType as ShipType]
|
||||
return null
|
||||
})
|
||||
|
||||
// 类型安全:单位成本(处理cost vs baseCost差异)
|
||||
const unitCost = computed(() => {
|
||||
if (props.type === 'ship') return SHIPS[props.itemType as ShipType].cost
|
||||
if (props.type === 'defense') return DEFENSES[props.itemType as DefenseType].cost
|
||||
return { metal: 0, crystal: 0, deuterium: 0 }
|
||||
})
|
||||
|
||||
// 类型安全:单位建造时间(处理buildTime vs baseTime差异,应用加成)
|
||||
const unitBuildTime = computed(() => {
|
||||
if (props.type === 'ship') {
|
||||
return shipLogic.calculateShipBuildTime(
|
||||
props.itemType as ShipType,
|
||||
1, // 单个单位
|
||||
activeBonuses.value.buildingSpeedBonus,
|
||||
roboticsFactoryLevel.value,
|
||||
naniteFactoryLevel.value
|
||||
)
|
||||
}
|
||||
if (props.type === 'defense') {
|
||||
return shipLogic.calculateDefenseBuildTime(
|
||||
props.itemType as DefenseType,
|
||||
1, // 单个单位
|
||||
activeBonuses.value.buildingSpeedBonus,
|
||||
roboticsFactoryLevel.value,
|
||||
naniteFactoryLevel.value
|
||||
)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// 建筑/科技:等级范围
|
||||
const levelRange = computed(() => {
|
||||
if (props.type !== 'building' && props.type !== 'technology') return []
|
||||
const current = props.currentLevel || 0
|
||||
const levels = []
|
||||
for (let i = current; i <= current + 10; i++) {
|
||||
levels.push(i)
|
||||
}
|
||||
return levels
|
||||
})
|
||||
|
||||
// 建筑/科技:获取某个等级的数据
|
||||
const getLevelData = (level: number) => {
|
||||
if (level === 0) {
|
||||
return {
|
||||
cost: { metal: 0, crystal: 0, deuterium: 0, darkMatter: 0 },
|
||||
time: 0,
|
||||
production: 0,
|
||||
consumption: 0,
|
||||
points: 0,
|
||||
capacity: 0,
|
||||
fleetStorage: 0,
|
||||
spaceBonus: 0,
|
||||
buildSpeedBonus: 0,
|
||||
researchSpeedBonus: 0
|
||||
}
|
||||
}
|
||||
|
||||
if (props.type === 'building') {
|
||||
const buildingType = props.itemType as BuildingType
|
||||
const cost = buildingLogic.calculateBuildingCost(buildingType, level)
|
||||
|
||||
// 使用实际的工厂等级和加成计算建造时间
|
||||
const time = buildingLogic.calculateBuildingTime(
|
||||
buildingType,
|
||||
level,
|
||||
activeBonuses.value.buildingSpeedBonus,
|
||||
roboticsFactoryLevel.value,
|
||||
naniteFactoryLevel.value
|
||||
)
|
||||
|
||||
let production = 0
|
||||
let consumption = 0
|
||||
let capacity = 0
|
||||
let fleetStorage = 0
|
||||
let spaceBonus = 0
|
||||
let buildSpeedBonus = 0
|
||||
let researchSpeedBonus = 0
|
||||
|
||||
// 应用资源产量加成
|
||||
const resourceBonus = 1 + (activeBonuses.value.resourceProductionBonus || 0) / 100
|
||||
const energyBonus = 1 + (activeBonuses.value.energyProductionBonus || 0) / 100
|
||||
const storageBonus = 1 + (activeBonuses.value.storageCapacityBonus || 0) / 100
|
||||
const baseCapacity = 10000
|
||||
|
||||
if (buildingType === 'metalMine') {
|
||||
production = Math.floor(1500 * level * Math.pow(1.5, level) * resourceBonus)
|
||||
consumption = Math.floor(10 * level * Math.pow(1.1, level))
|
||||
} else if (buildingType === 'crystalMine') {
|
||||
production = Math.floor(1000 * level * Math.pow(1.5, level) * resourceBonus)
|
||||
consumption = Math.floor(10 * level * Math.pow(1.1, level))
|
||||
} else if (buildingType === 'deuteriumSynthesizer') {
|
||||
production = Math.floor(500 * level * Math.pow(1.5, level) * resourceBonus)
|
||||
consumption = Math.floor(10 * level * Math.pow(1.1, level))
|
||||
} else if (buildingType === 'solarPlant') {
|
||||
production = Math.floor(50 * level * Math.pow(1.1, level) * energyBonus)
|
||||
} else if (buildingType === 'metalStorage') {
|
||||
capacity = Math.floor(baseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'crystalStorage') {
|
||||
capacity = Math.floor(baseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'deuteriumTank') {
|
||||
capacity = Math.floor(baseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'darkMatterCollector') {
|
||||
capacity = 1000 + level * 100
|
||||
production = Math.floor(25 * level * Math.pow(1.5, level))
|
||||
} else if (buildingType === 'darkMatterTank') {
|
||||
const darkMatterBaseCapacity = 1000
|
||||
capacity = Math.floor(darkMatterBaseCapacity * Math.pow(2, level) * storageBonus)
|
||||
} else if (buildingType === 'fusionReactor') {
|
||||
production = Math.floor(150 * level * Math.pow(1.15, level))
|
||||
} else if (buildingType === 'shipyard') {
|
||||
fleetStorage = 1000 * level
|
||||
} else if (buildingType === 'terraformer') {
|
||||
spaceBonus = 5
|
||||
} else if (buildingType === 'lunarBase') {
|
||||
spaceBonus = 5
|
||||
} else if (buildingType === 'roboticsFactory') {
|
||||
buildSpeedBonus = level
|
||||
} else if (buildingType === 'naniteFactory') {
|
||||
buildSpeedBonus = level * 2
|
||||
} else if (buildingType === 'researchLab') {
|
||||
researchSpeedBonus = level
|
||||
}
|
||||
|
||||
const points = pointsLogic.calculateBuildingPoints(buildingType, level - 1, level)
|
||||
return { cost, time, production, consumption, points, capacity, fleetStorage, spaceBonus, buildSpeedBonus, researchSpeedBonus }
|
||||
} else {
|
||||
const techType = props.itemType as TechnologyType
|
||||
const cost = researchLogic.calculateTechnologyCost(techType, level)
|
||||
|
||||
// 使用实际的研究所等级和加成计算研究时间
|
||||
const time = researchLogic.calculateTechnologyTime(
|
||||
techType,
|
||||
level - 1,
|
||||
activeBonuses.value.researchSpeedBonus,
|
||||
researchLabLevel.value
|
||||
)
|
||||
|
||||
let researchSpeedBonus = 0
|
||||
if (techType === 'energyTechnology') {
|
||||
researchSpeedBonus = level
|
||||
}
|
||||
|
||||
const points = pointsLogic.calculateTechnologyPoints(techType, level - 1, level)
|
||||
return { cost, time, production: 0, consumption: 0, points, capacity: 0, fleetStorage: 0, spaceBonus: 0, buildSpeedBonus: 0, researchSpeedBonus }
|
||||
}
|
||||
}
|
||||
|
||||
// 建筑/科技:累积统计
|
||||
const totalStats = computed(() => {
|
||||
if (props.type !== 'building' && props.type !== 'technology') {
|
||||
return { metal: 0, crystal: 0, deuterium: 0, points: 0 }
|
||||
}
|
||||
|
||||
let metal = 0,
|
||||
crystal = 0,
|
||||
deuterium = 0,
|
||||
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 pointsPerUnit = computed(() => {
|
||||
if (props.type === 'ship') return pointsLogic.calculateShipPoints(props.itemType as ShipType, 1)
|
||||
if (props.type === 'defense') return pointsLogic.calculateDefensePoints(props.itemType as DefenseType, 1)
|
||||
return 0
|
||||
})
|
||||
|
||||
// 舰船/防御:批量成本
|
||||
const batchCost = computed(() => ({
|
||||
metal: unitCost.value.metal * quantity.value,
|
||||
crystal: unitCost.value.crystal * quantity.value,
|
||||
deuterium: unitCost.value.deuterium * quantity.value
|
||||
}))
|
||||
|
||||
// 舰船/防御:批量积分
|
||||
const batchPoints = computed(() => {
|
||||
if (props.type === 'ship') return pointsLogic.calculateShipPoints(props.itemType as ShipType, quantity.value)
|
||||
if (props.type === 'defense') return pointsLogic.calculateDefensePoints(props.itemType as DefenseType, quantity.value)
|
||||
return 0
|
||||
})
|
||||
</script>
|
||||
232
src/components/NpcRelationCard.vue
Normal file
232
src/components/NpcRelationCard.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
{{ npc.name }}
|
||||
<Badge :variant="statusBadgeVariant">
|
||||
{{ statusText }}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription class="mt-1">
|
||||
{{ npc.planets.length }} {{ t('diplomacy.planets') }}
|
||||
<span v-if="npc.allies && npc.allies.length > 0" class="ml-2">· {{ npc.allies.length }} {{ t('diplomacy.allies') }}</span>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- 好感度进度条 -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-muted-foreground">{{ t('diplomacy.reputation') }}</span>
|
||||
<span class="font-semibold" :class="reputationColor">{{ reputation > 0 ? '+' : '' }}{{ reputation }}</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<!-- 背景进度条 -->
|
||||
<div class="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<!-- 负值部分(左侧,红色) -->
|
||||
<div
|
||||
v-if="reputation < 0"
|
||||
class="h-full bg-red-500 dark:bg-red-600 absolute right-1/2"
|
||||
:style="{ width: `${Math.abs(reputation) / 2}%` }"
|
||||
/>
|
||||
<!-- 正值部分(右侧,绿色) -->
|
||||
<div
|
||||
v-if="reputation > 0"
|
||||
class="h-full bg-green-500 dark:bg-green-600 absolute left-1/2"
|
||||
:style="{ width: `${reputation / 2}%` }"
|
||||
/>
|
||||
</div>
|
||||
<!-- 中心线 -->
|
||||
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-border" />
|
||||
</div>
|
||||
<div class="flex justify-between text-xs text-muted-foreground">
|
||||
<span>-100</span>
|
||||
<span>0</span>
|
||||
<span>+100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 盟友信息 -->
|
||||
<div v-if="npc.allies && npc.allies.length > 0" class="pt-2 border-t">
|
||||
<p class="text-sm text-muted-foreground mb-2">{{ t('diplomacy.alliedWith') }}:</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-for="allyId in npc.allies.slice(0, 3)" :key="allyId" variant="outline" class="text-xs">
|
||||
{{ getAllyName(allyId) }}
|
||||
</Badge>
|
||||
<Badge v-if="npc.allies.length > 3" variant="outline" class="text-xs">
|
||||
+{{ npc.allies.length - 3 }} {{ t('diplomacy.more') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button size="sm" variant="outline" class="flex-1" @click="handleGiftResources">
|
||||
<Gift class="h-4 w-4 mr-2" />
|
||||
{{ t('diplomacy.actions.gift') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" class="flex-1" @click="handleViewPlanets">
|
||||
<Globe class="h-4 w-4 mr-2" />
|
||||
{{ t('diplomacy.actions.viewPlanets') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 最近活动 -->
|
||||
<div v-if="recentEvent" class="pt-2 border-t">
|
||||
<p class="text-xs text-muted-foreground mb-1">{{ t('diplomacy.lastEvent') }}:</p>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<component :is="getEventIcon(recentEvent.reason)" class="h-3 w-3" />
|
||||
<span>{{ getEventText(recentEvent.reason) }}</span>
|
||||
<span class="text-muted-foreground">{{ formatTime(Date.now() - recentEvent.timestamp) }} {{ t('diplomacy.ago') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNPCStore } from '@/stores/npcStore'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Gift, Globe, Sword, Eye, Trash2 } from 'lucide-vue-next'
|
||||
import { RelationStatus, DiplomaticEventType } from '@/types/game'
|
||||
import type { DiplomaticRelation, NPC } from '@/types/game'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
npc: NPC
|
||||
relation?: DiplomaticRelation
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const npcStore = useNPCStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 好感度值
|
||||
const reputation = computed(() => props.relation?.reputation || 0)
|
||||
|
||||
// 关系状态
|
||||
const status = computed(() => props.relation?.status || RelationStatus.Neutral)
|
||||
|
||||
// 关系状态文本
|
||||
const statusText = computed(() => {
|
||||
switch (status.value) {
|
||||
case RelationStatus.Friendly:
|
||||
return t('diplomacy.status.friendly')
|
||||
case RelationStatus.Hostile:
|
||||
return t('diplomacy.status.hostile')
|
||||
default:
|
||||
return t('diplomacy.status.neutral')
|
||||
}
|
||||
})
|
||||
|
||||
// 关系状态Badge样式
|
||||
const statusBadgeVariant = computed(() => {
|
||||
switch (status.value) {
|
||||
case RelationStatus.Friendly:
|
||||
return 'default'
|
||||
case RelationStatus.Hostile:
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'secondary'
|
||||
}
|
||||
})
|
||||
|
||||
// 好感度颜色
|
||||
const reputationColor = computed(() => {
|
||||
if (reputation.value >= 20) return 'text-green-600 dark:text-green-400'
|
||||
if (reputation.value <= -20) return 'text-red-600 dark:text-red-400'
|
||||
return 'text-muted-foreground'
|
||||
})
|
||||
|
||||
// 最近的外交事件
|
||||
const recentEvent = computed(() => {
|
||||
if (!props.relation?.history || props.relation.history.length === 0) return null
|
||||
return props.relation.history[props.relation.history.length - 1]
|
||||
})
|
||||
|
||||
// 获取盟友名称
|
||||
const getAllyName = (allyId: string) => {
|
||||
const ally = npcStore.npcs.find(n => n.id === allyId)
|
||||
return ally?.name || allyId.substring(0, 8)
|
||||
}
|
||||
|
||||
// 获取事件图标
|
||||
const getEventIcon = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return Gift
|
||||
case DiplomaticEventType.Attack:
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return Sword
|
||||
case DiplomaticEventType.Spy:
|
||||
return Eye
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return Trash2
|
||||
default:
|
||||
return Gift
|
||||
}
|
||||
}
|
||||
|
||||
// 获取事件文本
|
||||
const getEventText = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case DiplomaticEventType.GiftResources:
|
||||
return t('diplomacy.events.gift')
|
||||
case DiplomaticEventType.Attack:
|
||||
return t('diplomacy.events.attack')
|
||||
case DiplomaticEventType.AllyAttacked:
|
||||
return t('diplomacy.events.allyAttacked')
|
||||
case DiplomaticEventType.Spy:
|
||||
return t('diplomacy.events.spy')
|
||||
case DiplomaticEventType.StealDebris:
|
||||
return t('diplomacy.events.stealDebris')
|
||||
default:
|
||||
return eventType
|
||||
}
|
||||
}
|
||||
|
||||
// 赠送资源
|
||||
const handleGiftResources = () => {
|
||||
// 跳转到舰队页面,自动选择第一个NPC星球
|
||||
if (props.npc.planets.length > 0) {
|
||||
const targetPlanet = props.npc.planets[0]
|
||||
if (!targetPlanet) return
|
||||
|
||||
router.push({
|
||||
path: '/fleet',
|
||||
query: {
|
||||
galaxy: targetPlanet.position.galaxy,
|
||||
system: targetPlanet.position.system,
|
||||
position: targetPlanet.position.position,
|
||||
gift: '1'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 查看星球
|
||||
const handleViewPlanets = () => {
|
||||
// 跳转到星系视图,定位到第一个NPC星球,并传递NPC ID用于高亮
|
||||
if (props.npc.planets.length > 0) {
|
||||
const targetPlanet = props.npc.planets[0]
|
||||
if (!targetPlanet) return
|
||||
|
||||
router.push({
|
||||
path: '/galaxy',
|
||||
query: {
|
||||
galaxy: targetPlanet.position.galaxy,
|
||||
system: targetPlanet.position.system,
|
||||
highlightNpc: props.npc.id
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<span class="cursor-pointer underline decoration-dotted underline-offset-4 touch-manipulation">{{ abbreviatedValue }}</span>
|
||||
<span class="cursor-pointer underline decoration-dotted underline-offset-4 touch-manipulation">{{ formatNumber(value, 1) }}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-2" side="top" align="center">
|
||||
<p class="font-mono text-sm">{{ formattedValue }}</p>
|
||||
@@ -12,6 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
value: number
|
||||
@@ -21,30 +22,4 @@
|
||||
const formattedValue = computed(() => {
|
||||
return props.value.toLocaleString()
|
||||
})
|
||||
|
||||
// 缩写格式的数字
|
||||
const abbreviatedValue = computed(() => {
|
||||
const num = props.value
|
||||
|
||||
// 小于1000直接显示
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// 1000 - 999,999: 使用 K (千)
|
||||
if (num < 1000000) {
|
||||
const k = num / 1000
|
||||
return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`
|
||||
}
|
||||
|
||||
// 1,000,000 - 999,999,999: 使用 M (百万)
|
||||
if (num < 1000000000) {
|
||||
const m = num / 1000000
|
||||
return m % 1 === 0 ? `${m}M` : `${m.toFixed(1)}M`
|
||||
}
|
||||
|
||||
// 1,000,000,000+: 使用 B (十亿)
|
||||
const b = num / 1000000000
|
||||
return b % 1 === 0 ? `${b}B` : `${b.toFixed(1)}B`
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Eye class="h-5 w-5" />
|
||||
{{ t('messagesView.spyReport') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="report">
|
||||
{{ formatDate(report.timestamp) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ScrollableDialogContent container-class="sm:max-w-2xl max-h-[90vh]">
|
||||
<template #header>
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Eye class="h-5 w-5" />
|
||||
{{ t('messagesView.spyReport') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="report">
|
||||
{{ formatDate(report.timestamp) }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</template>
|
||||
|
||||
<div v-if="report" class="space-y-4">
|
||||
<!-- 目标星球信息 -->
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<p class="text-sm font-medium mb-2">{{ t('messagesView.targetPlanet') }}</p>
|
||||
<p v-if="targetPlanet" class="text-xs text-muted-foreground">
|
||||
{{ targetPlanet.name }} [{{ targetPlanet.position.galaxy }}:{{ targetPlanet.position.system }}:{{
|
||||
targetPlanet.position.position
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ report.targetPlanetName }} [{{ report.targetPosition.galaxy }}:{{ report.targetPosition.system }}:{{
|
||||
report.targetPosition.position
|
||||
}}]
|
||||
</p>
|
||||
<p v-else class="text-xs text-muted-foreground">{{ report.targetPlanetId }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 资源 -->
|
||||
@@ -80,17 +82,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</ScrollableDialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useUniverseStore } from '@/stores/universeStore'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { useGameConfig } from '@/composables/useGameConfig'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Dialog, ScrollableDialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import ResourceIcon from '@/components/ResourceIcon.vue'
|
||||
import { formatNumber, formatDate } from '@/utils/format'
|
||||
import { Eye } from 'lucide-vue-next'
|
||||
@@ -105,23 +105,11 @@
|
||||
(e: 'update:open', value: boolean): void
|
||||
}>()
|
||||
|
||||
const gameStore = useGameStore()
|
||||
const universeStore = useUniverseStore()
|
||||
const { t } = useI18n()
|
||||
const { SHIPS, DEFENSES, BUILDINGS } = useGameConfig()
|
||||
|
||||
const isOpen = ref(props.open)
|
||||
|
||||
// 获取目标星球信息
|
||||
const targetPlanet = computed(() => {
|
||||
if (!props.report) return null
|
||||
// 先从玩家星球中查找
|
||||
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.targetPlanetId)
|
||||
if (playerPlanet) return playerPlanet
|
||||
// 再从宇宙星球地图中查找
|
||||
return Object.values(universeStore.planets).find(p => p.id === props.report!.targetPlanetId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
newValue => {
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
<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">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.metal" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.crystal" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="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">
|
||||
+
|
||||
<NumberWithTooltip :value="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">
|
||||
-
|
||||
<NumberWithTooltip :value="getLevelData(level).consumption" />
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<span class="text-primary font-medium">
|
||||
+
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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 NumberWithTooltip from '@/components/NumberWithTooltip.vue'
|
||||
import * as buildingLogic from '@/logic/buildingLogic'
|
||||
import * as pointsLogic from '@/logic/pointsLogic'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
buildingType: BuildingType
|
||||
currentLevel: number
|
||||
}>()
|
||||
|
||||
// 等级范围:当前等级 +10
|
||||
const levelRange = computed(() => {
|
||||
const end = props.currentLevel + 10
|
||||
const levels = []
|
||||
for (let i = props.currentLevel; 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
|
||||
|
||||
// 资源矿产量(与 resourceLogic.ts 保持一致)
|
||||
if (props.buildingType === 'metalMine') {
|
||||
production = Math.floor(1500 * level * Math.pow(1.5, level))
|
||||
} else if (props.buildingType === 'crystalMine') {
|
||||
production = Math.floor(1000 * level * Math.pow(1.5, level))
|
||||
} else if (props.buildingType === 'deuteriumSynthesizer') {
|
||||
production = Math.floor(500 * level * Math.pow(1.5, level))
|
||||
}
|
||||
|
||||
// 能量产出(与 resourceLogic.ts 保持一致)
|
||||
if (props.buildingType === 'solarPlant') {
|
||||
production = Math.floor(50 * 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 }
|
||||
})
|
||||
</script>
|
||||
@@ -1,179 +0,0 @@
|
||||
<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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="batchCost.metal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.crystal') }}:</span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="batchCost.crystal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.deuterium') }}:</span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="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') }}: +
|
||||
<NumberWithTooltip :value="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 NumberWithTooltip from '@/components/NumberWithTooltip.vue'
|
||||
import { Sword, Shield, ShieldCheck } from 'lucide-vue-next'
|
||||
import * as pointsLogic from '@/logic/pointsLogic'
|
||||
import { DEFENSES } from '@/config/gameConfig'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
@@ -1,221 +0,0 @@
|
||||
<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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="batchCost.metal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.crystal') }}:</span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="batchCost.crystal" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ t('resources.deuterium') }}:</span>
|
||||
<span class="font-medium">
|
||||
<NumberWithTooltip :value="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') }}: +
|
||||
<NumberWithTooltip :value="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 NumberWithTooltip from '@/components/NumberWithTooltip.vue'
|
||||
import { Sword, Shield, ShieldCheck, Zap, Package, Fuel } from 'lucide-vue-next'
|
||||
import * as pointsLogic from '@/logic/pointsLogic'
|
||||
import { SHIPS } from '@/config/gameConfig'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
@@ -1,158 +0,0 @@
|
||||
<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">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.metal" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="getLevelData(level).cost.crystal" />
|
||||
</TableCell>
|
||||
<TableCell class="text-center text-sm">
|
||||
<NumberWithTooltip :value="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">
|
||||
+
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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">
|
||||
<NumberWithTooltip :value="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 NumberWithTooltip from '@/components/NumberWithTooltip.vue'
|
||||
import * as researchLogic from '@/logic/researchLogic'
|
||||
import * as pointsLogic from '@/logic/pointsLogic'
|
||||
import { formatTime } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
technologyType: TechnologyType
|
||||
currentLevel: number
|
||||
}>()
|
||||
|
||||
// 等级范围:当前等级 +10
|
||||
const levelRange = computed(() => {
|
||||
const end = props.currentLevel + 10
|
||||
const levels = []
|
||||
for (let i = props.currentLevel; 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 }
|
||||
})
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@ 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',
|
||||
'inline-flex items-center justify-center rounded-sm border h-5 min-w-5 px-1 text-xs font-medium tabular-nums select-none 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: {
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
</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 '.'
|
||||
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']
|
||||
}
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button'
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: 'button'
|
||||
})
|
||||
</script>
|
||||
|
||||
35
src/components/ui/checkbox/Checkbox.vue
Normal file
35
src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator data-slot="checkbox-indicator" class="grid place-content-center text-current transition-none">
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
1
src/components/ui/checkbox/index.ts
Normal file
1
src/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from './Checkbox.vue'
|
||||
71
src/components/ui/dialog/ScrollableDialogContent.vue
Normal file
71
src/components/ui/dialog/ScrollableDialogContent.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="scrollable-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 w-[calc(100vw-3rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 sm:w-auto flex flex-col p-0',
|
||||
containerClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- 固定的头部 -->
|
||||
<div class="shrink-0 px-4 pt-4 pb-3 sm:px-6 sm:pt-6 sm:pb-4 border-b">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<!-- 可滚动的内容区域 -->
|
||||
<div class="overflow-y-auto px-4 py-3 sm:px-6 sm:py-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 可选的固定底部 -->
|
||||
<div v-if="$slots.footer" class="shrink-0 px-4 pb-4 pt-3 sm:px-6 sm:pb-6 sm:pt-4 border-t">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<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 z-10"
|
||||
>
|
||||
<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 & {
|
||||
containerClass?: HTMLAttributes['class']
|
||||
showCloseButton?: boolean
|
||||
}
|
||||
>(),
|
||||
{
|
||||
showCloseButton: true
|
||||
}
|
||||
)
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'containerClass')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -6,5 +6,6 @@ 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 ScrollableDialogContent } from './ScrollableDialogContent.vue'
|
||||
export { default as DialogTitle } from './DialogTitle.vue'
|
||||
export { default as DialogTrigger } from './DialogTrigger.vue'
|
||||
|
||||
28
src/components/ui/pagination/Pagination.vue
Normal file
28
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination"
|
||||
v-bind="forwarded"
|
||||
:class="cn('mx-auto flex w-full justify-center', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationRootEmits, PaginationRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PaginationRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<
|
||||
PaginationRootProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
const emits = defineEmits<PaginationRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
22
src/components/ui/pagination/PaginationContent.vue
Normal file
22
src/components/ui/pagination/PaginationContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<PaginationList
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination-content"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('flex flex-row items-center gap-1', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationList>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PaginationList } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<PaginationListProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
25
src/components/ui/pagination/PaginationEllipsis.vue
Normal file
25
src/components/ui/pagination/PaginationEllipsis.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<PaginationEllipsis
|
||||
data-slot="pagination-ellipsis"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('flex size-9 items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<MoreHorizontal class="size-4" />
|
||||
<span class="sr-only">More pages</span>
|
||||
</slot>
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationEllipsisProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { MoreHorizontal } from 'lucide-vue-next'
|
||||
import { PaginationEllipsis } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
38
src/components/ui/pagination/PaginationFirst.vue
Normal file
38
src/components/ui/pagination/PaginationFirst.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<PaginationFirst
|
||||
data-slot="pagination-first"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
<span class="hidden sm:block">First</span>
|
||||
</slot>
|
||||
</PaginationFirst>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationFirstProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronLeftIcon } from 'lucide-vue-next'
|
||||
import { PaginationFirst, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PaginationFirstProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>(),
|
||||
{
|
||||
size: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
42
src/components/ui/pagination/PaginationItem.vue
Normal file
42
src/components/ui/pagination/PaginationItem.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<PaginationListItem
|
||||
data-slot="pagination-item"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size
|
||||
}),
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PaginationListItem>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationListItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { PaginationListItem } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PaginationListItemProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
isActive?: boolean
|
||||
}
|
||||
>(),
|
||||
{
|
||||
size: 'icon'
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')
|
||||
</script>
|
||||
38
src/components/ui/pagination/PaginationLast.vue
Normal file
38
src/components/ui/pagination/PaginationLast.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<PaginationLast
|
||||
data-slot="pagination-last"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Last</span>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</PaginationLast>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationLastProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import { PaginationLast, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PaginationLastProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>(),
|
||||
{
|
||||
size: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
38
src/components/ui/pagination/PaginationNext.vue
Normal file
38
src/components/ui/pagination/PaginationNext.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<PaginationNext
|
||||
data-slot="pagination-next"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</PaginationNext>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationNextProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRightIcon } from 'lucide-vue-next'
|
||||
import { PaginationNext, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PaginationNextProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>(),
|
||||
{
|
||||
size: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
38
src/components/ui/pagination/PaginationPrevious.vue
Normal file
38
src/components/ui/pagination/PaginationPrevious.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<PaginationPrev
|
||||
data-slot="pagination-previous"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
</slot>
|
||||
</PaginationPrev>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PaginationPrevProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronLeftIcon } from 'lucide-vue-next'
|
||||
import { PaginationPrev, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PaginationPrevProps & {
|
||||
size?: ButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>(),
|
||||
{
|
||||
size: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
8
src/components/ui/pagination/index.ts
Normal file
8
src/components/ui/pagination/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as Pagination } from './Pagination.vue'
|
||||
export { default as PaginationContent } from './PaginationContent.vue'
|
||||
export { default as PaginationEllipsis } from './PaginationEllipsis.vue'
|
||||
export { default as PaginationFirst } from './PaginationFirst.vue'
|
||||
export { default as PaginationItem } from './PaginationItem.vue'
|
||||
export { default as PaginationLast } from './PaginationLast.vue'
|
||||
export { default as PaginationNext } from './PaginationNext.vue'
|
||||
export { default as PaginationPrevious } from './PaginationPrevious.vue'
|
||||
@@ -4,12 +4,13 @@
|
||||
data-sidebar="menu-badge"
|
||||
:class="
|
||||
cn(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||
'text-sidebar-foreground pointer-events-none flex h-5 min-w-5 items-center justify-center rounded-sm px-1 text-xs font-medium tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'absolute right-1',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
'group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:right-0 group-data-[collapsible=icon]:-top-1 group-data-[collapsible=icon]:h-4 group-data-[collapsible=icon]:min-w-4',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -53,19 +53,19 @@
|
||||
passive: (props.open === undefined) as false
|
||||
}) as Ref<boolean>
|
||||
|
||||
function setOpen(value: boolean) {
|
||||
const setOpen = (value: boolean) => {
|
||||
open.value = value // emits('update:open', value)
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
}
|
||||
|
||||
function setOpenMobile(value: boolean) {
|
||||
const setOpenMobile = (value: boolean) => {
|
||||
openMobile.value = value
|
||||
}
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
function toggleSidebar() {
|
||||
const toggleSidebar = () => {
|
||||
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
|
||||
}
|
||||
|
||||
|
||||
19
src/components/ui/tabs/Tabs.vue
Normal file
19
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<TabsRoot v-slot="slotProps" data-slot="tabs" v-bind="forwarded" :class="cn('flex flex-col gap-2', props.class)">
|
||||
<slot v-bind="slotProps" />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
17
src/components/ui/tabs/TabsContent.vue
Normal file
17
src/components/ui/tabs/TabsContent.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<TabsContentRoot data-slot="tabs-content" :class="cn('flex-1 outline-none', props.class)" v-bind="delegatedProps">
|
||||
<slot />
|
||||
</TabsContentRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabsContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsContent as TabsContentRoot } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
27
src/components/ui/tabs/TabsList.vue
Normal file
27
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<TabsListRoot
|
||||
data-slot="tabs-list"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted text-muted-foreground inline-flex w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
tabCount && tabCount > 3 ? (tabCount > 6 ? 'h-[85px] sm:h-9' : 'h-[65px] sm:h-9') : 'h-9',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsListRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsList as TabsListRoot } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsListProps & { class?: HTMLAttributes['class']; tabCount?: number }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'tabCount')
|
||||
</script>
|
||||
34
src/components/ui/tabs/TabsTrigger.vue
Normal file
34
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<TabsTriggerRoot
|
||||
data-slot="tabs-trigger"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-md data-[state=active]:border-border',
|
||||
'dark:data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-border dark:data-[state=active]:shadow-lg',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:outline-1',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsTriggerRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { TabsTrigger as TabsTriggerRoot, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
4
src/components/ui/tabs/index.ts
Normal file
4
src/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TabsContent } from './TabsContent.vue'
|
||||
export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||
Reference in New Issue
Block a user