feat: 新增NPC与外交逻辑,优化UI组件结构

重构并精简了部分UI组件,移除冗余弹窗与详情组件,新增NPC相关逻辑(npcBehaviorLogic、npcGrowthLogic、npcStore等)及外交逻辑(diplomaticLogic、DiplomacyView)。完善分页、标签、复选框等通用UI组件。优化战报弹窗,调整README下载链接为相对路径,修复部分国际化内容。
This commit is contained in:
谦君
2025-12-15 08:23:45 +08:00
parent 44580909a3
commit 9b9fda0400
164 changed files with 18628 additions and 2775 deletions

View File

@@ -1,131 +1,137 @@
<template>
<div class="container mx-auto p-4 sm:p-6 space-y-6">
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('simulatorView.title') }}</h1>
<!-- 标签切换 -->
<div class="flex gap-2 border-b">
<Button @click="activeTab = 'attacker'" :variant="activeTab === 'attacker' ? 'default' : 'ghost'" class="rounded-b-none">
<Sword />
{{ t('simulatorView.attacker') }}
</Button>
<Button @click="activeTab = 'defender'" :variant="activeTab === 'defender' ? 'default' : 'ghost'" class="rounded-b-none">
<Shield />
{{ t('simulatorView.defender') }}
</Button>
</div>
<!-- 攻击方配置 -->
<Card v-if="activeTab === 'attacker'">
<CardHeader>
<CardTitle>{{ t('simulatorView.attackerConfig') }}</CardTitle>
<CardDescription>{{ t('simulatorView.attackerConfigDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 舰队配置 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.fleet') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="shipType in Object.values(ShipType)" :key="shipType" class="space-y-1">
<Label :for="`attacker-${shipType}`" class="text-xs">{{ SHIPS[shipType].name }}</Label>
<Input :id="`attacker-${shipType}`" v-model.number="attackerFleet[shipType]" type="number" min="0" class="h-8" />
</div>
</div>
</div>
<!-- 科技等级 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.techLevels') }}</h3>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1">
<Label for="attacker-weapon" class="text-xs">{{ t('simulatorView.weapon') }}</Label>
<Input id="attacker-weapon" v-model.number="attackerTech.weapon" type="number" min="0" class="h-8" />
</div>
<div class="space-y-1">
<Label for="attacker-shield" class="text-xs">{{ t('simulatorView.shield') }}</Label>
<Input id="attacker-shield" v-model.number="attackerTech.shield" type="number" min="0" class="h-8" />
</div>
<div class="space-y-1">
<Label for="attacker-armor" class="text-xs">{{ t('simulatorView.armor') }}</Label>
<Input id="attacker-armor" v-model.number="attackerTech.armor" type="number" min="0" class="h-8" />
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 防守方配置 -->
<Card v-else>
<CardHeader>
<CardTitle>{{ t('simulatorView.defenderConfig') }}</CardTitle>
<CardDescription>{{ t('simulatorView.defenderConfigDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 舰队配置 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.fleet') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="shipType in Object.values(ShipType)" :key="shipType" class="space-y-1">
<Label :for="`defender-${shipType}`" class="text-xs">{{ SHIPS[shipType].name }}</Label>
<Input :id="`defender-${shipType}`" v-model.number="defenderFleet[shipType]" type="number" min="0" class="h-8" />
</div>
</div>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="attacker">
<Sword class="h-4 w-4 mr-2" />
{{ t('simulatorView.attacker') }}
</TabsTrigger>
<TabsTrigger value="defender">
<Shield class="h-4 w-4 mr-2" />
{{ t('simulatorView.defender') }}
</TabsTrigger>
</TabsList>
<!-- 防御设施 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.defenseStructures') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="defenseType in Object.values(DefenseType)" :key="defenseType" class="space-y-1">
<Label :for="`defense-${defenseType}`" class="text-xs">{{ DEFENSES[defenseType].name }}</Label>
<Input :id="`defense-${defenseType}`" v-model.number="defenderDefense[defenseType]" type="number" min="0" class="h-8" />
<!-- 攻击方配置 -->
<TabsContent value="attacker" class="mt-4">
<Card>
<CardHeader>
<CardTitle>{{ t('simulatorView.attackerConfig') }}</CardTitle>
<CardDescription>{{ t('simulatorView.attackerConfigDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 舰队配置 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.fleet') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="shipType in Object.values(ShipType)" :key="shipType" class="space-y-1">
<Label :for="`attacker-${shipType}`" class="text-xs">{{ SHIPS[shipType].name }}</Label>
<Input
:id="`attacker-${shipType}`"
:model-value="attackerFleet[shipType] ?? 0"
@update:model-value="val => (attackerFleet[shipType] = typeof val === 'number' ? val : 0)"
type="number"
min="0"
class="h-8"
/>
</div>
</div>
</div>
</div>
</div>
<!-- 科技等级 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.techLevels') }}</h3>
<div class="grid grid-cols-3 gap-3">
<div v-for="techType in techTypes" :key="techType" class="space-y-1">
<Label :for="`attacker-${techType}`" class="text-xs">{{ t(`simulatorView.${techType}`) }}</Label>
<Input :id="`attacker-${techType}`" v-model.number="attackerTech[techType]" type="number" min="0" class="h-8" />
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<!-- 科技等级 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.techLevels') }}</h3>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1">
<Label for="defender-weapon" class="text-xs">{{ t('simulatorView.weapon') }}</Label>
<Input id="defender-weapon" v-model.number="defenderTech.weapon" type="number" min="0" class="h-8" />
<!-- 防守方配置 -->
<TabsContent value="defender" class="mt-4">
<Card>
<CardHeader>
<CardTitle>{{ t('simulatorView.defenderConfig') }}</CardTitle>
<CardDescription>{{ t('simulatorView.defenderConfigDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 舰队配置 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.fleet') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="shipType in Object.values(ShipType)" :key="shipType" class="space-y-1">
<Label :for="`defender-${shipType}`" class="text-xs">{{ SHIPS[shipType].name }}</Label>
<Input
:id="`defender-${shipType}`"
:model-value="defenderFleet[shipType] ?? 0"
@update:model-value="val => (defenderFleet[shipType] = typeof val === 'number' ? val : 0)"
type="number"
min="0"
class="h-8"
/>
</div>
</div>
</div>
<div class="space-y-1">
<Label for="defender-shield" class="text-xs">{{ t('simulatorView.shield') }}</Label>
<Input id="defender-shield" v-model.number="defenderTech.shield" type="number" min="0" class="h-8" />
</div>
<div class="space-y-1">
<Label for="defender-armor" class="text-xs">{{ t('simulatorView.armor') }}</Label>
<Input id="defender-armor" v-model.number="defenderTech.armor" type="number" min="0" class="h-8" />
</div>
</div>
</div>
<!-- 防守方资源 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.defenderResources') }}</h3>
<div class="grid grid-cols-3 gap-3">
<div class="space-y-1">
<Label for="defender-metal" class="text-xs flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ t('resources.metal') }}
</Label>
<Input id="defender-metal" v-model.number="defenderResources.metal" type="number" min="0" class="h-8" />
<!-- 防御设施 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.defenseStructures') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div v-for="defenseType in Object.values(DefenseType)" :key="defenseType" class="space-y-1">
<Label :for="`defense-${defenseType}`" class="text-xs">{{ DEFENSES[defenseType].name }}</Label>
<Input
:id="`defense-${defenseType}`"
:model-value="defenderDefense[defenseType] ?? 0"
@update:model-value="val => (defenderDefense[defenseType] = typeof val === 'number' ? val : 0)"
type="number"
min="0"
class="h-8"
/>
</div>
</div>
</div>
<div class="space-y-1">
<Label for="defender-crystal" class="text-xs flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ t('resources.crystal') }}
</Label>
<Input id="defender-crystal" v-model.number="defenderResources.crystal" type="number" min="0" class="h-8" />
<!-- 科技等级 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.techLevels') }}</h3>
<div class="grid grid-cols-3 gap-3">
<div v-for="techType in techTypes" :key="techType" class="space-y-1">
<Label :for="`defender-${techType}`" class="text-xs">{{ t(`simulatorView.${techType}`) }}</Label>
<Input :id="`defender-${techType}`" v-model.number="defenderTech[techType]" type="number" min="0" class="h-8" />
</div>
</div>
</div>
<div class="space-y-1">
<Label for="defender-deuterium" class="text-xs flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ t('resources.deuterium') }}
</Label>
<Input id="defender-deuterium" v-model.number="defenderResources.deuterium" type="number" min="0" class="h-8" />
<!-- 防守方资源 -->
<div>
<h3 class="text-sm font-medium mb-3">{{ t('simulatorView.defenderResources') }}</h3>
<div class="grid grid-cols-3 gap-3">
<div v-for="resourceType in resourceTypes" :key="resourceType.key" class="space-y-1">
<Label :for="`defender-${resourceType.key}`" class="text-xs flex items-center gap-1">
<ResourceIcon :type="resourceType.key" size="sm" />
{{ t(`resources.${resourceType.key}`) }}
</Label>
<Input
:id="`defender-${resourceType.key}`"
v-model.number="defenderResources[resourceType.key]"
type="number"
min="0"
class="h-8"
/>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<!-- 操作按钮 -->
<div class="flex gap-2">
@@ -149,6 +155,7 @@
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -163,19 +170,23 @@
const { t } = useI18n()
const { SHIPS, DEFENSES } = useGameConfig()
// 科技类型配置
const techTypes = ['weapon', 'shield', 'armor'] as const
// 资源类型配置(用于防守方资源输入)
const resourceTypes = [{ key: 'metal' as const }, { key: 'crystal' as const }, { key: 'deuterium' as const }]
// 动态初始化所有舰船类型为0
const initializeFleet = (): Partial<Fleet> => {
const fleet: Partial<Fleet> = {}
Object.values(ShipType).forEach(shipType => {
fleet[shipType] = 0
})
return fleet
}
// 攻击方配置
const attackerFleet = ref<Partial<Fleet>>({
[ShipType.LightFighter]: 0,
[ShipType.HeavyFighter]: 0,
[ShipType.Cruiser]: 0,
[ShipType.Battleship]: 0,
[ShipType.SmallCargo]: 0,
[ShipType.LargeCargo]: 0,
[ShipType.ColonyShip]: 0,
[ShipType.Recycler]: 0,
[ShipType.EspionageProbe]: 0,
[ShipType.DarkMatterHarvester]: 0
})
const attackerFleet = ref<Partial<Fleet>>(initializeFleet())
const activeTab = ref('attacker')
@@ -186,29 +197,18 @@
})
// 防守方配置
const defenderFleet = ref<Partial<Fleet>>({
[ShipType.LightFighter]: 0,
[ShipType.HeavyFighter]: 0,
[ShipType.Cruiser]: 0,
[ShipType.Battleship]: 0,
[ShipType.SmallCargo]: 0,
[ShipType.LargeCargo]: 0,
[ShipType.ColonyShip]: 0,
[ShipType.Recycler]: 0,
[ShipType.EspionageProbe]: 0,
[ShipType.DarkMatterHarvester]: 0
})
const defenderFleet = ref<Partial<Fleet>>(initializeFleet())
const defenderDefense = ref<Partial<Record<DefenseType, number>>>({
[DefenseType.RocketLauncher]: 0,
[DefenseType.LightLaser]: 0,
[DefenseType.HeavyLaser]: 0,
[DefenseType.GaussCannon]: 0,
[DefenseType.IonCannon]: 0,
[DefenseType.PlasmaTurret]: 0,
[DefenseType.SmallShieldDome]: 0,
[DefenseType.LargeShieldDome]: 0
})
// 动态初始化所有防御类型为0
const initializeDefense = (): Partial<Record<DefenseType, number>> => {
const defense: Partial<Record<DefenseType, number>> = {}
Object.values(DefenseType).forEach(defenseType => {
defense[defenseType] = 0
})
return defense
}
const defenderDefense = ref<Partial<Record<DefenseType, number>>>(initializeDefense())
const defenderTech = ref({
weapon: 0,
@@ -294,15 +294,9 @@
// 重置模拟
const resetSimulation = () => {
Object.keys(attackerFleet.value).forEach(key => {
attackerFleet.value[key as ShipType] = 0
})
Object.keys(defenderFleet.value).forEach(key => {
defenderFleet.value[key as ShipType] = 0
})
Object.keys(defenderDefense.value).forEach(key => {
defenderDefense.value[key as DefenseType] = 0
})
attackerFleet.value = initializeFleet()
defenderFleet.value = initializeFleet()
defenderDefense.value = initializeDefense()
attackerTech.value = { weapon: 0, shield: 0, armor: 0 }
defenderTech.value = { weapon: 0, shield: 0, armor: 0 }
simulationResult.value = null

View File

@@ -1,12 +1,30 @@
<template>
<div v-if="planet" class="container mx-auto p-4 sm:p-6">
<div class="flex justify-between items-center mb-4 sm:mb-6 gap-2">
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('buildingsView.title') }}</h1>
<div class="text-xs sm:text-sm">
<span class="flex items-center gap-1.5 text-muted-foreground">
<Grid3x3 :size="14" />
{{ getUsedSpace(planet) }} / {{ planet.maxSpace }}
</span>
<h1 class="text-2xl sm:text-3xl font-bold mb-4 sm:mb-6">{{ t('buildingsView.title') }}</h1>
<!-- 占地显示 -->
<div class="mb-4 sm:mb-6 p-3 sm:p-4 bg-muted/50 rounded-lg border">
<div class="flex items-center justify-between">
<div class="text-sm sm:text-base font-medium flex items-center gap-2">
<Grid3x3 :size="16" />
{{ t('buildingsView.spaceUsage') }}:
</div>
<div class="text-sm sm:text-base font-bold">
<span :class="getUsedSpace(planet) > planet.maxSpace ? 'text-destructive' : 'text-primary'">
{{ formatNumber(getUsedSpace(planet)) }}
</span>
<span class="text-muted-foreground mx-1">/</span>
<span>{{ formatNumber(planet.maxSpace) }}</span>
</div>
</div>
<div class="mt-2">
<div class="w-full bg-background rounded-full h-2.5 sm:h-3 overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="getUsedSpace(planet) > planet.maxSpace ? 'bg-destructive' : 'bg-primary'"
:style="{ width: `${Math.min((getUsedSpace(planet) / planet.maxSpace) * 100, 100)}%` }"
/>
</div>
</div>
</div>
@@ -16,64 +34,46 @@
<CardUnlockOverlay :requirements="BUILDINGS[buildingType].requirements" :currentLevel="getBuildingLevel(buildingType)" />
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="mb-2">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
<CardTitle
class="text-base sm:text-lg cursor-pointer hover:text-primary transition-colors"
class="text-sm sm:text-base lg:text-lg cursor-pointer hover:text-primary transition-colors underline decoration-dotted underline-offset-4 order-2 sm:order-1"
@click="detailDialog.openBuilding(buildingType, getBuildingLevel(buildingType))"
>
{{ BUILDINGS[buildingType].name }}
</CardTitle>
<CardDescription class="text-xs sm:text-sm">{{ BUILDINGS[buildingType].description }}</CardDescription>
<Badge variant="secondary" class="text-xs whitespace-nowrap self-start order-1 sm:order-2">
Lv {{ getBuildingLevel(buildingType) }}
</Badge>
</div>
<Badge variant="secondary" class="text-xs whitespace-nowrap flex-shrink-0">Lv {{ getBuildingLevel(buildingType) }}</Badge>
</div>
<CardDescription class="text-xs sm:text-sm">{{ BUILDINGS[buildingType].description }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-3">
<div class="text-xs sm:text-sm space-y-1.5 sm:space-y-2">
<p class="text-muted-foreground mb-1 sm:mb-2">{{ t('buildingsView.upgradeCost') }}:</p>
<div class="space-y-1 sm:space-y-1.5">
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="
getResourceCostColor(planet.resources.metal, getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).metal)
"
>
{{ formatNumber(getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).metal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="
resourceType.key !== 'darkMatter' || getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).darkMatter > 0
"
class="flex items-center gap-1.5 sm:gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="
getResourceCostColor(
planet.resources.crystal,
getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).crystal
planet.resources[resourceType.key],
getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1)[resourceType.key]
)
"
>
{{ formatNumber(getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).crystal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="
getResourceCostColor(
planet.resources.deuterium,
getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).deuterium
)
"
>
{{ formatNumber(getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1).deuterium) }}
{{ formatNumber(getBuildingCost(buildingType, getBuildingLevel(buildingType) + 1)[resourceType.key]) }}
</span>
</div>
</div>
@@ -113,6 +113,9 @@
<span>{{ formatNumber(getDemolishRefund(buildingType).metal) }} {{ t('resources.metal') }}</span>
<span>{{ formatNumber(getDemolishRefund(buildingType).crystal) }} {{ t('resources.crystal') }}</span>
<span>{{ formatNumber(getDemolishRefund(buildingType).deuterium) }} {{ t('resources.deuterium') }}</span>
<span v-if="getDemolishRefund(buildingType).darkMatter > 0">
{{ formatNumber(getDemolishRefund(buildingType).darkMatter) }} {{ t('resources.darkMatter') }}
</span>
</div>
</div>
</div>
@@ -121,7 +124,35 @@
</div>
<!-- 提示对话框 -->
<AlertDialog ref="alertDialog" />
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 拆除确认对话框 -->
<AlertDialog :open="demolishConfirmOpen" @update:open="demolishConfirmOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ t('buildingsView.confirmDemolish') }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ demolishConfirmMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="confirmDemolish">{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -138,24 +169,51 @@
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/ResourceIcon.vue'
import CardUnlockOverlay from '@/components/CardUnlockOverlay.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Clock, Grid3x3 } from 'lucide-vue-next'
import { formatNumber, formatTime, getResourceCostColor } from '@/utils/format'
import * as buildingLogic from '@/logic/buildingLogic'
import * as buildingValidation from '@/logic/buildingValidation'
import * as publicLogic from '@/logic/publicLogic'
import * as officerLogic from '@/logic/officerLogic'
const gameStore = useGameStore()
const detailDialog = useDetailDialogStore()
const { t } = useI18n()
const { BUILDINGS, TECHNOLOGIES } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
// 拆除确认对话框状态
const demolishConfirmOpen = ref(false)
const demolishConfirmMessage = ref('')
const pendingDemolishBuilding = ref<BuildingType | null>(null)
// 资源类型配置(用于成本显示)
const costResourceTypes = [
{ key: 'metal' as const },
{ key: 'crystal' as const },
{ key: 'deuterium' as const },
{ key: 'darkMatter' as const }
]
// 根据星球类型过滤可用建筑
const availableBuildings = computed(() => {
const availableBuildings = computed<BuildingType[]>(() => {
if (!planet.value) return []
return Object.values(BuildingType).filter(buildingType => {
return (Object.values(BuildingType) as BuildingType[]).filter(buildingType => {
const config = BUILDINGS.value[buildingType]
if (planet.value!.isMoon) {
// 月球只能建造月球专属建筑
@@ -189,19 +247,17 @@
const handleUpgrade = (buildingType: BuildingType) => {
// 检查前置条件
if (!checkUpgradeRequirements(buildingType)) {
alertDialog.value?.show({
title: t('common.requirementsNotMet'),
message: getRequirementsList(buildingType)
})
alertDialogTitle.value = t('common.requirementsNotMet')
alertDialogMessage.value = getRequirementsList(buildingType)
alertDialogOpen.value = true
return
}
const success = upgradeBuilding(buildingType)
if (!success) {
alertDialog.value?.show({
title: t('buildingsView.upgradeFailed'),
message: t('buildingsView.upgradeFailedMessage')
})
alertDialogTitle.value = t('buildingsView.upgradeFailed')
alertDialogMessage.value = t('buildingsView.upgradeFailedMessage')
alertDialogOpen.value = true
}
}
@@ -291,7 +347,13 @@
return false
}
if (planet.value.buildQueue.length > 0) return false
// 检查建造队列是否已满(只计算建筑类型的队列项)
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
const maxQueue = publicLogic.getMaxBuildQueue(planet.value, bonuses.additionalBuildQueue)
const buildingQueueCount = planet.value.buildQueue.filter(item => item.type === 'building' || item.type === 'demolish').length
if (buildingQueueCount >= maxQueue) {
return false
}
// 检查前置条件
const validation = buildingValidation.validateBuildingUpgrade(
@@ -307,7 +369,8 @@
return (
planet.value.resources.metal >= cost.metal &&
planet.value.resources.crystal >= cost.crystal &&
planet.value.resources.deuterium >= cost.deuterium
planet.value.resources.deuterium >= cost.deuterium &&
planet.value.resources.darkMatter >= cost.darkMatter
)
}
@@ -316,7 +379,21 @@
}
const getBuildingTime = (buildingType: BuildingType, targetLevel: number): number => {
return buildingLogic.calculateBuildingTime(buildingType, targetLevel)
if (!planet.value) return 0
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
// 获取机器人工厂和纳米工厂等级
const roboticsFactoryLevel = planet.value.buildings[BuildingType.RoboticsFactory] || 0
const naniteFactoryLevel = planet.value.buildings[BuildingType.NaniteFactory] || 0
return buildingLogic.calculateBuildingTime(
buildingType,
targetLevel,
bonuses.buildingSpeedBonus,
roboticsFactoryLevel,
naniteFactoryLevel
)
}
// 拆除建筑
@@ -330,20 +407,47 @@
}
const handleDemolish = (buildingType: BuildingType) => {
const success = demolishBuilding(buildingType)
if (!success) {
alertDialog.value?.show({
title: t('buildingsView.demolishFailed'),
message: t('buildingsView.demolishFailedMessage')
})
const buildingName = BUILDINGS.value[buildingType].name
const refund = getDemolishRefund(buildingType)
demolishConfirmMessage.value = `${t('buildingsView.confirmDemolishMessage')}: ${buildingName}
${t('buildingsView.demolishRefund')}:
${t('resources.metal')}: ${formatNumber(refund.metal)}
${t('resources.crystal')}: ${formatNumber(refund.crystal)}
${t('resources.deuterium')}: ${formatNumber(refund.deuterium)}${refund.darkMatter > 0 ? `\n${t('resources.darkMatter')}: ${formatNumber(refund.darkMatter)}` : ''}`
pendingDemolishBuilding.value = buildingType
demolishConfirmOpen.value = true
}
const confirmDemolish = () => {
if (pendingDemolishBuilding.value) {
const success = demolishBuilding(pendingDemolishBuilding.value)
if (!success) {
alertDialogTitle.value = t('buildingsView.demolishFailed')
alertDialogMessage.value = t('buildingsView.demolishFailedMessage')
alertDialogOpen.value = true
}
}
demolishConfirmOpen.value = false
pendingDemolishBuilding.value = null
}
// 检查是否可以拆除
const canDemolish = (buildingType: BuildingType): boolean => {
if (!planet.value) return false
if (planet.value.buildQueue.length > 0) return false
return getBuildingLevel(buildingType) > 0
if (getBuildingLevel(buildingType) <= 0) return false
// 检查建造队列是否已满(只计算建筑类型的队列项)
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
const maxQueue = publicLogic.getMaxBuildQueue(planet.value, bonuses.additionalBuildQueue)
const buildingQueueCount = planet.value.buildQueue.filter(item => item.type === 'building' || item.type === 'demolish').length
if (buildingQueueCount >= maxQueue) {
return false
}
return true
}
// 获取拆除返还资源

View File

@@ -9,20 +9,20 @@
<Card v-for="defenseType in Object.values(DefenseType)" :key="defenseType" class="relative">
<CardUnlockOverlay :requirements="DEFENSES[defenseType].requirements" />
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="mb-2">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
<CardTitle
class="text-base sm:text-lg cursor-pointer hover:text-primary transition-colors"
class="text-sm sm:text-base lg:text-lg cursor-pointer hover:text-primary transition-colors underline decoration-dotted underline-offset-4 order-2 sm:order-1"
@click="detailDialog.openDefense(defenseType)"
>
{{ DEFENSES[defenseType].name }}
</CardTitle>
<CardDescription class="text-xs sm:text-sm">{{ DEFENSES[defenseType].description }}</CardDescription>
<Badge variant="secondary" class="text-xs whitespace-nowrap self-start order-1 sm:order-2">
{{ planet.defense[defenseType] }}
</Badge>
</div>
<Badge variant="secondary" class="text-xs whitespace-nowrap flex-shrink-0">
{{ planet.defense[defenseType] }}
</Badge>
</div>
<CardDescription class="text-xs sm:text-sm">{{ DEFENSES[defenseType].description }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-3 sm:space-y-4">
@@ -48,34 +48,19 @@
<div class="text-xs sm:text-sm space-y-1.5 sm:space-y-2">
<p class="text-muted-foreground mb-1 sm:mb-2">{{ t('defenseView.unitCost') }}:</p>
<div class="space-y-1 sm:space-y-1.5">
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="resourceType.key !== 'darkMatter' || DEFENSES[defenseType].cost.darkMatter > 0"
class="flex items-center gap-1.5 sm:gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.metal, DEFENSES[defenseType].cost.metal)"
:class="getResourceCostColor(planet.resources[resourceType.key], DEFENSES[defenseType].cost[resourceType.key])"
>
{{ formatNumber(DEFENSES[defenseType].cost.metal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.crystal, DEFENSES[defenseType].cost.crystal)"
>
{{ formatNumber(DEFENSES[defenseType].cost.crystal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.deuterium, DEFENSES[defenseType].cost.deuterium)"
>
{{ formatNumber(DEFENSES[defenseType].cost.deuterium) }}
{{ formatNumber(DEFENSES[defenseType].cost[resourceType.key]) }}
</span>
</div>
</div>
@@ -101,34 +86,19 @@
<div v-if="quantities[defenseType] > 0" class="text-xs sm:text-sm space-y-1.5 sm:space-y-2 p-2.5 sm:p-3 bg-muted rounded-lg">
<p class="font-medium text-muted-foreground">{{ t('defenseView.totalCost') }}:</p>
<div class="space-y-1 sm:space-y-1.5">
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="resourceType.key !== 'darkMatter' || getTotalCost(defenseType).darkMatter > 0"
class="flex items-center gap-1.5 sm:gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.metal, getTotalCost(defenseType).metal)"
:class="getResourceCostColor(planet.resources[resourceType.key], getTotalCost(defenseType)[resourceType.key])"
>
{{ formatNumber(getTotalCost(defenseType).metal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.crystal, getTotalCost(defenseType).crystal)"
>
{{ formatNumber(getTotalCost(defenseType).crystal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.deuterium, getTotalCost(defenseType).deuterium)"
>
{{ formatNumber(getTotalCost(defenseType).deuterium) }}
{{ formatNumber(getTotalCost(defenseType)[resourceType.key]) }}
</span>
</div>
</div>
@@ -143,7 +113,19 @@
</div>
<!-- 提示对话框 -->
<AlertDialog ref="alertDialog" />
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -160,7 +142,15 @@
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/ResourceIcon.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import UnlockRequirement from '@/components/UnlockRequirement.vue'
import CardUnlockOverlay from '@/components/CardUnlockOverlay.vue'
import { formatNumber, getResourceCostColor } from '@/utils/format'
@@ -172,7 +162,19 @@
const { t } = useI18n()
const { DEFENSES } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
// 资源类型配置(用于成本显示)
const costResourceTypes = [
{ key: 'metal' as const },
{ key: 'crystal' as const },
{ key: 'deuterium' as const },
{ key: 'darkMatter' as const }
]
// 每种防御设施的建造数量
const quantities = ref<Record<DefenseType, number>>({
@@ -184,6 +186,8 @@
[DefenseType.PlasmaTurret]: 0,
[DefenseType.SmallShieldDome]: 0,
[DefenseType.LargeShieldDome]: 0,
[DefenseType.AntiBallisticMissile]: 0,
[DefenseType.InterplanetaryMissile]: 0,
[DefenseType.PlanetaryShield]: 0
})
@@ -205,19 +209,17 @@
const handleBuild = (defenseType: DefenseType) => {
const quantity = quantities.value[defenseType]
if (quantity <= 0) {
alertDialog.value?.show({
title: t('defenseView.inputError'),
message: t('defenseView.inputErrorMessage')
})
alertDialogTitle.value = t('defenseView.inputError')
alertDialogMessage.value = t('defenseView.inputErrorMessage')
alertDialogOpen.value = true
return
}
const success = buildDefense(defenseType, quantity)
if (!success) {
alertDialog.value?.show({
title: t('defenseView.buildFailed'),
message: t('defenseView.buildFailedMessage')
})
alertDialogTitle.value = t('defenseView.buildFailed')
alertDialogMessage.value = t('defenseView.buildFailedMessage')
alertDialogOpen.value = true
} else {
quantities.value[defenseType] = 0
}
@@ -240,14 +242,16 @@
const totalCost = {
metal: config.cost.metal * quantity,
crystal: config.cost.crystal * quantity,
deuterium: config.cost.deuterium * quantity
deuterium: config.cost.deuterium * quantity,
darkMatter: config.cost.darkMatter * quantity
}
return (
publicLogic.checkRequirements(planet.value, gameStore.player.technologies, config.requirements) &&
planet.value.resources.metal >= totalCost.metal &&
planet.value.resources.crystal >= totalCost.crystal &&
planet.value.resources.deuterium >= totalCost.deuterium
planet.value.resources.deuterium >= totalCost.deuterium &&
planet.value.resources.darkMatter >= totalCost.darkMatter
)
}
@@ -258,7 +262,8 @@
return {
metal: config.cost.metal * quantity,
crystal: config.cost.crystal * quantity,
deuterium: config.cost.deuterium * quantity
deuterium: config.cost.deuterium * quantity,
darkMatter: config.cost.darkMatter * quantity
}
}
</script>

390
src/views/DiplomacyView.vue Normal file
View File

@@ -0,0 +1,390 @@
<template>
<div class="container mx-auto p-4 sm:p-6 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('diplomacy.title') }}</h1>
<p class="text-sm text-muted-foreground mt-1">{{ t('diplomacy.description') }}</p>
</div>
</div>
<!-- 关系状态过滤标签 -->
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">
{{ t('diplomacy.tabs.all') }}
<Badge variant="secondary" class="ml-2">{{ allNpcs.length }}</Badge>
</TabsTrigger>
<TabsTrigger value="friendly">
{{ t('diplomacy.tabs.friendly') }}
<Badge variant="secondary" class="ml-2">{{ friendlyNpcs.length }}</Badge>
</TabsTrigger>
<TabsTrigger value="neutral">
{{ t('diplomacy.tabs.neutral') }}
<Badge variant="secondary" class="ml-2">{{ neutralNpcs.length }}</Badge>
</TabsTrigger>
<TabsTrigger value="hostile">
{{ t('diplomacy.tabs.hostile') }}
<Badge variant="secondary" class="ml-2">{{ hostileNpcs.length }}</Badge>
</TabsTrigger>
</TabsList>
<!-- 全部NPC -->
<TabsContent value="all" class="space-y-4 mt-6">
<div v-if="allNpcs.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('diplomacy.noNpcs') }}
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NpcRelationCard v-for="npc in paginatedAllNpcs" :key="npc.id" :npc="npc" :relation="getRelation(npc.id)" />
</div>
<Pagination
v-if="totalPagesAll > 1"
v-model:page="currentPage.all"
:total="allNpcs.length"
:items-per-page="ITEMS_PER_PAGE"
:sibling-count="1"
show-edges
class="mt-6"
>
<PaginationContent>
<PaginationPrevious>{{ t('pagination.previous') }}</PaginationPrevious>
<template v-for="(pageNum, index) in pageNumbersAll" :key="index">
<PaginationItem v-if="typeof pageNum === 'number'" :value="pageNum" :is-active="pageNum === currentPage.all">
{{ pageNum }}
</PaginationItem>
<span v-else class="px-2 text-muted-foreground">{{ pageNum }}</span>
</template>
<PaginationNext>{{ t('pagination.next') }}</PaginationNext>
</PaginationContent>
</Pagination>
</template>
</TabsContent>
<!-- 友好NPC -->
<TabsContent value="friendly" class="space-y-4 mt-6">
<div v-if="friendlyNpcs.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('diplomacy.noFriendlyNpcs') }}
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NpcRelationCard v-for="npc in paginatedFriendlyNpcs" :key="npc.id" :npc="npc" :relation="getRelation(npc.id)" />
</div>
<Pagination
v-if="totalPagesFriendly > 1"
v-model:page="currentPage.friendly"
:total="friendlyNpcs.length"
:items-per-page="ITEMS_PER_PAGE"
:sibling-count="1"
show-edges
class="mt-6"
>
<PaginationContent>
<PaginationPrevious>{{ t('pagination.previous') }}</PaginationPrevious>
<template v-for="(pageNum, index) in pageNumbersFriendly" :key="index">
<PaginationItem v-if="typeof pageNum === 'number'" :value="pageNum" :is-active="pageNum === currentPage.friendly">
{{ pageNum }}
</PaginationItem>
<span v-else class="px-2 text-muted-foreground">{{ pageNum }}</span>
</template>
<PaginationNext>{{ t('pagination.next') }}</PaginationNext>
</PaginationContent>
</Pagination>
</template>
</TabsContent>
<!-- 中立NPC -->
<TabsContent value="neutral" class="space-y-4 mt-6">
<div v-if="neutralNpcs.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('diplomacy.noNeutralNpcs') }}
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NpcRelationCard v-for="npc in paginatedNeutralNpcs" :key="npc.id" :npc="npc" :relation="getRelation(npc.id)" />
</div>
<Pagination
v-if="totalPagesNeutral > 1"
v-model:page="currentPage.neutral"
:total="neutralNpcs.length"
:items-per-page="ITEMS_PER_PAGE"
:sibling-count="1"
show-edges
class="mt-6"
>
<PaginationContent>
<PaginationPrevious>{{ t('pagination.previous') }}</PaginationPrevious>
<template v-for="(pageNum, index) in pageNumbersNeutral" :key="index">
<PaginationItem v-if="typeof pageNum === 'number'" :value="pageNum" :is-active="pageNum === currentPage.neutral">
{{ pageNum }}
</PaginationItem>
<span v-else class="px-2 text-muted-foreground">{{ pageNum }}</span>
</template>
<PaginationNext>{{ t('pagination.next') }}</PaginationNext>
</PaginationContent>
</Pagination>
</template>
</TabsContent>
<!-- 敌对NPC -->
<TabsContent value="hostile" class="space-y-4 mt-6">
<div v-if="hostileNpcs.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('diplomacy.noHostileNpcs') }}
</div>
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<NpcRelationCard v-for="npc in paginatedHostileNpcs" :key="npc.id" :npc="npc" :relation="getRelation(npc.id)" />
</div>
<Pagination
v-if="totalPagesHostile > 1"
v-model:page="currentPage.hostile"
:total="hostileNpcs.length"
:items-per-page="ITEMS_PER_PAGE"
:sibling-count="1"
show-edges
class="mt-6"
>
<PaginationContent>
<PaginationPrevious>{{ t('pagination.previous') }}</PaginationPrevious>
<template v-for="(pageNum, index) in pageNumbersHostile" :key="index">
<PaginationItem v-if="typeof pageNum === 'number'" :value="pageNum" :is-active="pageNum === currentPage.hostile">
{{ pageNum }}
</PaginationItem>
<span v-else class="px-2 text-muted-foreground">{{ pageNum }}</span>
</template>
<PaginationNext>{{ t('pagination.next') }}</PaginationNext>
</PaginationContent>
</Pagination>
</template>
</TabsContent>
</Tabs>
<!-- 外交报告历史 -->
<Card v-if="diplomaticReports.length > 0">
<CardHeader>
<CardTitle>{{ t('diplomacy.recentEvents') }}</CardTitle>
<CardDescription>{{ t('diplomacy.recentEventsDescription') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-2 max-h-96 overflow-y-auto">
<div
v-for="report in diplomaticReports"
:key="report.id"
class="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div class="flex-shrink-0 mt-0.5">
<component :is="getEventIcon(report.eventType)" class="h-5 w-5" :class="getEventIconColor(report.reputationChange)" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium">{{ report.npcName }}</span>
<Badge :variant="getReputationBadgeVariant(report.reputationChange)" class="text-xs">
{{ report.reputationChange > 0 ? '+' : '' }}{{ report.reputationChange }}
</Badge>
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs">
{{ getStatusText(report.newStatus) }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">{{ report.message }}</p>
<p class="text-xs text-muted-foreground mt-1">{{ formatTime(Date.now() - report.timestamp) }} {{ t('diplomacy.ago') }}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from '@/components/ui/pagination'
import NpcRelationCard from '@/components/NpcRelationCard.vue'
import { Gift, Sword, Eye, Trash2 } from 'lucide-vue-next'
import { RelationStatus, DiplomaticEventType } from '@/types/game'
import type { DiplomaticRelation, DiplomaticReport } from '@/types/game'
import { formatTime } from '@/utils/format'
const gameStore = useGameStore()
const npcStore = useNPCStore()
const { t } = useI18n()
const activeTab = ref('all')
// 分页状态
const ITEMS_PER_PAGE = 20
const currentPage = ref<Record<string, number>>({
all: 1,
friendly: 1,
neutral: 1,
hostile: 1
})
// 获取玩家对NPC的关系
const getRelation = (npcId: string): DiplomaticRelation | undefined => {
return gameStore.player.diplomaticRelations?.[npcId]
}
// 按关系状态分类NPC
const allNpcs = computed(() => npcStore.npcs)
const friendlyNpcs = computed(() => {
return npcStore.npcs.filter(npc => {
const relation = getRelation(npc.id)
return relation?.status === RelationStatus.Friendly
})
})
const neutralNpcs = computed(() => {
return npcStore.npcs.filter(npc => {
const relation = getRelation(npc.id)
return !relation || relation.status === RelationStatus.Neutral
})
})
const hostileNpcs = computed(() => {
return npcStore.npcs.filter(npc => {
const relation = getRelation(npc.id)
return relation?.status === RelationStatus.Hostile
})
})
// 分页辅助函数
const getPaginatedNpcs = (npcs: typeof allNpcs.value, tabKey: string) => {
const page = currentPage.value[tabKey] || 1
const start = (page - 1) * ITEMS_PER_PAGE
const end = start + ITEMS_PER_PAGE
return npcs.slice(start, end)
}
const getTotalPages = (npcs: typeof allNpcs.value) => {
return Math.ceil(npcs.length / ITEMS_PER_PAGE)
}
// 分页后的NPC列表
const paginatedAllNpcs = computed(() => getPaginatedNpcs(allNpcs.value, 'all'))
const paginatedFriendlyNpcs = computed(() => getPaginatedNpcs(friendlyNpcs.value, 'friendly'))
const paginatedNeutralNpcs = computed(() => getPaginatedNpcs(neutralNpcs.value, 'neutral'))
const paginatedHostileNpcs = computed(() => getPaginatedNpcs(hostileNpcs.value, 'hostile'))
// 总页数
const totalPagesAll = computed(() => getTotalPages(allNpcs.value))
const totalPagesFriendly = computed(() => getTotalPages(friendlyNpcs.value))
const totalPagesNeutral = computed(() => getTotalPages(neutralNpcs.value))
const totalPagesHostile = computed(() => getTotalPages(hostileNpcs.value))
// 生成页码列表用于分页UI
const getPageNumbers = (currentPageNum: number, totalPages: number) => {
const pages: (number | string)[] = []
const maxVisible = 5 // 最多显示5个页码
if (totalPages <= maxVisible) {
// 如果总页数少于等于5显示全部
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
// 总是显示第1页
pages.push(1)
if (currentPageNum > 3) {
pages.push('...')
}
// 计算中间显示的页码范围
const start = Math.max(2, currentPageNum - 1)
const end = Math.min(totalPages - 1, currentPageNum + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (currentPageNum < totalPages - 2) {
pages.push('...')
}
// 总是显示最后一页
pages.push(totalPages)
}
return pages
}
// 各标签页的页码列表
const pageNumbersAll = computed(() => getPageNumbers(currentPage.value.all || 1, totalPagesAll.value))
const pageNumbersFriendly = computed(() => getPageNumbers(currentPage.value.friendly || 1, totalPagesFriendly.value))
const pageNumbersNeutral = computed(() => getPageNumbers(currentPage.value.neutral || 1, totalPagesNeutral.value))
const pageNumbersHostile = computed(() => getPageNumbers(currentPage.value.hostile || 1, totalPagesHostile.value))
// 外交报告最近20条按时间倒序
const diplomaticReports = computed(() => {
const reports = gameStore.player.diplomaticReports || []
return [...reports].sort((a, b) => b.timestamp - a.timestamp).slice(0, 20)
})
// 获取事件图标
const getEventIcon = (eventType: DiplomaticReport['eventType']) => {
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 getEventIconColor = (reputationChange: number) => {
if (reputationChange > 0) return 'text-green-600 dark:text-green-400'
if (reputationChange < 0) return 'text-red-600 dark:text-red-400'
return 'text-muted-foreground'
}
// 获取好感度Badge样式
const getReputationBadgeVariant = (change: number) => {
if (change > 0) return 'default'
if (change < 0) return 'destructive'
return 'secondary'
}
// 获取关系状态Badge样式
const getStatusBadgeVariant = (status: RelationStatus) => {
switch (status) {
case RelationStatus.Friendly:
return 'default'
case RelationStatus.Hostile:
return 'destructive'
default:
return 'secondary'
}
}
// 获取关系状态文本
const getStatusText = (status: RelationStatus) => {
switch (status) {
case RelationStatus.Friendly:
return t('diplomacy.status.friendly')
case RelationStatus.Hostile:
return t('diplomacy.status.hostile')
default:
return t('diplomacy.status.neutral')
}
}
</script>

View File

@@ -6,297 +6,330 @@
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('fleetView.title') }}</h1>
<!-- 标签切换 -->
<div class="flex gap-2 border-b">
<Button @click="activeTab = 'fleet'" :variant="activeTab === 'fleet' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('fleetView.fleetOverview') }}
</Button>
<Button @click="activeTab = 'send'" :variant="activeTab === 'send' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('fleetView.sendFleet') }}
</Button>
<Button @click="activeTab = 'missions'" :variant="activeTab === 'missions' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('fleetView.flightMissions') }}
<Badge v-if="gameStore.player.fleetMissions.length > 0" variant="secondary" class="ml-1">
{{ gameStore.player.fleetMissions.length }}
</Badge>
</Button>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-3">
<TabsTrigger value="fleet">{{ t('fleetView.fleetOverview') }}</TabsTrigger>
<TabsTrigger value="send">{{ t('fleetView.sendFleet') }}</TabsTrigger>
<TabsTrigger value="missions">
{{ t('fleetView.flightMissions') }}
<Badge v-if="gameStore.player.fleetMissions.length > 0" variant="destructive" class="ml-1">
{{ gameStore.player.fleetMissions.length }}
</Badge>
</TabsTrigger>
</TabsList>
<!-- 舰队总览 -->
<div v-if="activeTab === 'fleet'">
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.currentPlanetFleet') }}</CardTitle>
<CardDescription>
{{ planet.name }} [{{ planet.position.galaxy }}:{{ planet.position.system }}:{{ planet.position.position }}]
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
<div v-for="(count, shipType) in planet.fleet" :key="shipType" class="p-3 sm:p-4 border rounded-lg space-y-2">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-sm sm:text-base">{{ SHIPS[shipType].name }}</h3>
<p class="text-xl sm:text-2xl font-bold">{{ formatNumber(count) }}</p>
<!-- 舰队总览 -->
<TabsContent value="fleet" class="mt-4">
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.currentPlanetFleet') }}</CardTitle>
<CardDescription>
{{ planet.name }} [{{ planet.position.galaxy }}:{{ planet.position.system }}:{{ planet.position.position }}]
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
<div v-for="(count, shipType) in planet.fleet" :key="shipType" class="p-3 sm:p-4 border rounded-lg space-y-2">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-sm sm:text-base">{{ SHIPS[shipType].name }}</h3>
<p class="text-xl sm:text-2xl font-bold">{{ formatNumber(count) }}</p>
</div>
</div>
<div class="text-xs sm:text-sm text-muted-foreground space-y-1">
<p>{{ t('fleetView.attack') }}: {{ SHIPS[shipType].attack }}</p>
<p>{{ t('fleetView.shield') }}: {{ SHIPS[shipType].shield }}</p>
<p>{{ t('fleetView.armor') }}: {{ SHIPS[shipType].armor }}</p>
<p>{{ t('fleetView.speed') }}: {{ formatNumber(SHIPS[shipType].speed) }}</p>
<p>{{ t('fleetView.cargo') }}: {{ formatNumber(SHIPS[shipType].cargoCapacity) }}</p>
</div>
</div>
<div class="text-xs sm:text-sm text-muted-foreground space-y-1">
<p>{{ t('fleetView.attack') }}: {{ SHIPS[shipType].attack }}</p>
<p>{{ t('fleetView.shield') }}: {{ SHIPS[shipType].shield }}</p>
<p>{{ t('fleetView.armor') }}: {{ SHIPS[shipType].armor }}</p>
<p>{{ t('fleetView.speed') }}: {{ formatNumber(SHIPS[shipType].speed) }}</p>
<p>{{ t('fleetView.cargo') }}: {{ formatNumber(SHIPS[shipType].cargoCapacity) }}</p>
</div>
</CardContent>
</Card>
</TabsContent>
<!-- 派遣舰队 -->
<TabsContent value="send" class="mt-4 space-y-4">
<!-- 舰队任务槽位信息 -->
<Card>
<CardContent class="py-4">
<div class="flex justify-between items-center">
<span class="text-sm font-medium">{{ t('fleetView.fleetMissionSlots') }}:</span>
<span class="text-sm font-bold">{{ gameStore.player.fleetMissions.length }} / {{ maxFleetMissions }}</span>
</div>
</CardContent>
</Card>
<!-- 选择舰队 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.selectFleet') }}</CardTitle>
<CardDescription>{{ t('fleetView.selectFleetDescription') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div v-for="(count, shipType) in planet.fleet" :key="shipType" class="space-y-2">
<Label :for="`ship-${shipType}`" class="text-xs sm:text-sm">
{{ SHIPS[shipType].name }} ({{ t('fleetView.available') }}: {{ count }})
</Label>
<div class="flex gap-2">
<Input
:id="`ship-${shipType}`"
v-model.number="selectedFleet[shipType]"
type="number"
min="0"
:max="count"
placeholder="0"
class="text-sm"
/>
<Button @click="selectedFleet[shipType] = count" variant="outline" size="sm">{{ t('fleetView.all') }}</Button>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
<!-- 派遣舰队 -->
<div v-if="activeTab === 'send'" class="space-y-4">
<!-- 舰队任务槽位信息 -->
<Card>
<CardContent class="py-4">
<div class="flex justify-between items-center">
<span class="text-sm font-medium">{{ t('fleetView.fleetMissionSlots') }}:</span>
<span class="text-sm font-bold">{{ gameStore.player.fleetMissions.length }} / {{ maxFleetMissions }}</span>
</div>
</CardContent>
</Card>
<!-- 目标坐标 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.targetCoordinates') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<div class="space-y-2">
<Label for="galaxy" class="text-xs sm:text-sm">{{ t('fleetView.galaxy') }}</Label>
<Input id="galaxy" v-model.number="targetPosition.galaxy" type="number" min="1" max="9" placeholder="1" />
</div>
<div class="space-y-2">
<Label for="system" class="text-xs sm:text-sm">{{ t('fleetView.system') }}</Label>
<Input id="system" v-model.number="targetPosition.system" type="number" min="1" max="10" placeholder="1" />
</div>
<div class="space-y-2">
<Label for="position" class="text-xs sm:text-sm">{{ t('fleetView.position') }}</Label>
<Input id="position" v-model.number="targetPosition.position" type="number" min="1" max="10" placeholder="1" />
</div>
</div>
</CardContent>
</Card>
<!-- 选择舰队 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.selectFleet') }}</CardTitle>
<CardDescription>{{ t('fleetView.selectFleetDescription') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div v-for="(count, shipType) in planet.fleet" :key="shipType" class="space-y-2">
<Label :for="`ship-${shipType}`" class="text-xs sm:text-sm">
{{ SHIPS[shipType].name }} ({{ t('fleetView.available') }}: {{ count }})
</Label>
<div class="flex gap-2">
<!-- 任务类型 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.missionType') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
v-for="mission in availableMissions"
:key="mission.type"
@click="selectedMission = mission.type"
:variant="selectedMission === mission.type ? 'default' : 'outline'"
class="justify-start"
>
<component :is="mission.icon" class="h-4 w-4 mr-2" />
{{ mission.name }}
</Button>
</div>
</CardContent>
</Card>
<!-- 运输资源仅运输任务 -->
<Card v-if="selectedMission === MissionType.Transport">
<CardHeader>
<CardTitle>{{ t('fleetView.transportResources') }}</CardTitle>
</CardHeader>
<CardContent>
<!-- 赠送模式切换仅当目标是NPC星球时显示 -->
<div v-if="targetNpc" class="mb-4 p-3 border rounded-lg bg-muted/50">
<div class="flex items-center gap-2 mb-2">
<Checkbox id="gift-mode" :default-value="isGiftMode" />
<Label for="gift-mode" class="flex items-center gap-2 cursor-pointer">
<Gift class="h-4 w-4" />
{{ t('fleetView.giftMode') }}
</Label>
</div>
<p class="text-xs text-muted-foreground">{{ t('fleetView.giftModeDescription') }} {{ targetNpc.name }}</p>
<div v-if="isGiftMode && (cargo.metal > 0 || cargo.crystal > 0 || cargo.deuterium > 0)" class="mt-2 text-xs">
<span class="text-muted-foreground">{{ t('fleetView.estimatedReputationGain') }}:</span>
<span class="ml-1 font-semibold text-green-600 dark:text-green-400">+{{ calculateGiftReputation() }}</span>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<div class="space-y-2">
<Label for="cargo-metal" class="text-xs sm:text-sm flex items-center gap-2">
<ResourceIcon type="metal" size="sm" />
{{ t('resources.metal') }} ({{ t('fleetView.available') }}: {{ formatNumber(planet.resources.metal) }})
</Label>
<Input id="cargo-metal" v-model.number="cargo.metal" type="number" min="0" :max="planet.resources.metal" placeholder="0" />
</div>
<div class="space-y-2">
<Label for="cargo-crystal" class="text-xs sm:text-sm flex items-center gap-2">
<ResourceIcon type="crystal" size="sm" />
{{ t('resources.crystal') }} ({{ t('fleetView.available') }}: {{ formatNumber(planet.resources.crystal) }})
</Label>
<Input
:id="`ship-${shipType}`"
v-model.number="selectedFleet[shipType]"
id="cargo-crystal"
v-model.number="cargo.crystal"
type="number"
min="0"
:max="count"
:max="planet.resources.crystal"
placeholder="0"
/>
</div>
<div class="space-y-2">
<Label for="cargo-deuterium" class="text-xs sm:text-sm flex items-center gap-2">
<ResourceIcon type="deuterium" size="sm" />
{{ t('resources.deuterium') }} ({{ t('fleetView.available') }}: {{ formatNumber(planet.resources.deuterium) }})
</Label>
<Input
id="cargo-deuterium"
v-model.number="cargo.deuterium"
type="number"
min="0"
:max="planet.resources.deuterium"
placeholder="0"
class="text-sm"
/>
<Button @click="selectedFleet[shipType] = count" variant="outline" size="sm">{{ t('fleetView.all') }}</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<p class="text-xs sm:text-sm text-muted-foreground mt-2">
{{ t('fleetView.totalCargoCapacity') }}: {{ formatNumber(getTotalCargoCapacity()) }} | {{ t('fleetView.used') }}:
{{ formatNumber(getTotalCargo()) }}
</p>
</CardContent>
</Card>
<!-- 目标坐标 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.targetCoordinates') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-3 gap-2 sm:gap-4">
<div class="space-y-2">
<Label for="galaxy" class="text-xs sm:text-sm">{{ t('fleetView.galaxy') }}</Label>
<Input id="galaxy" v-model.number="targetPosition.galaxy" type="number" min="1" max="9" placeholder="1" />
</div>
<div class="space-y-2">
<Label for="system" class="text-xs sm:text-sm">{{ t('fleetView.system') }}</Label>
<Input id="system" v-model.number="targetPosition.system" type="number" min="1" max="10" placeholder="1" />
</div>
<div class="space-y-2">
<Label for="position" class="text-xs sm:text-sm">{{ t('fleetView.position') }}</Label>
<Input id="position" v-model.number="targetPosition.position" type="number" min="1" max="10" placeholder="1" />
</div>
</div>
</CardContent>
</Card>
<!-- 任务类型 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.missionType') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
v-for="mission in availableMissions"
:key="mission.type"
@click="selectedMission = mission.type"
:variant="selectedMission === mission.type ? 'default' : 'outline'"
class="justify-start"
>
<component :is="mission.icon" class="h-4 w-4 mr-2" />
{{ mission.name }}
</Button>
</div>
</CardContent>
</Card>
<!-- 运输资源仅运输任务 -->
<Card v-if="selectedMission === MissionType.Transport">
<CardHeader>
<CardTitle>{{ t('fleetView.transportResources') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
<div class="space-y-2">
<Label for="cargo-metal" class="text-xs sm:text-sm flex items-center gap-2">
<ResourceIcon type="metal" size="sm" />
{{ t('resources.metal') }} ({{ t('fleetView.available') }}: {{ formatNumber(planet.resources.metal) }})
</Label>
<Input id="cargo-metal" v-model.number="cargo.metal" type="number" min="0" :max="planet.resources.metal" placeholder="0" />
</div>
<div class="space-y-2">
<Label for="cargo-crystal" class="text-xs sm:text-sm flex items-center gap-2">
<ResourceIcon type="crystal" size="sm" />
{{ t('resources.crystal') }} ({{ t('fleetView.available') }}: {{ formatNumber(planet.resources.crystal) }})
</Label>
<Input
id="cargo-crystal"
v-model.number="cargo.crystal"
type="number"
min="0"
:max="planet.resources.crystal"
placeholder="0"
/>
</div>
<div class="space-y-2">
<Label for="cargo-deuterium" class="text-xs sm:text-sm flex items-center gap-2">
<!-- 任务信息 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.missionInfo') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex justify-between text-xs sm:text-sm">
<span class="text-muted-foreground">{{ t('fleetView.fuelConsumption') }}:</span>
<span class="flex items-center gap-1.5">
<ResourceIcon type="deuterium" size="sm" />
{{ t('resources.deuterium') }} ({{ t('fleetView.available') }}: {{ formatNumber(planet.resources.deuterium) }})
</Label>
<Input
id="cargo-deuterium"
v-model.number="cargo.deuterium"
type="number"
min="0"
:max="planet.resources.deuterium"
placeholder="0"
/>
</div>
</div>
<p class="text-xs sm:text-sm text-muted-foreground mt-2">
{{ t('fleetView.totalCargoCapacity') }}: {{ formatNumber(getTotalCargoCapacity()) }} | {{ t('fleetView.used') }}:
{{ formatNumber(getTotalCargo()) }}
</p>
</CardContent>
</Card>
<!-- 任务信息 -->
<Card>
<CardHeader>
<CardTitle>{{ t('fleetView.missionInfo') }}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex justify-between text-xs sm:text-sm">
<span class="text-muted-foreground">{{ t('fleetView.fuelConsumption') }}:</span>
<span class="flex items-center gap-1.5">
<ResourceIcon type="deuterium" size="sm" />
<span :class="getFuelConsumption() > planet.resources.deuterium ? 'text-red-600 dark:text-red-400 font-medium' : ''">
{{ formatNumber(getFuelConsumption()) }}
<span :class="getFuelConsumption() > planet.resources.deuterium ? 'text-red-600 dark:text-red-400 font-medium' : ''">
{{ formatNumber(getFuelConsumption()) }}
</span>
<span class="text-muted-foreground">/ {{ formatNumber(planet.resources.deuterium) }}</span>
</span>
<span class="text-muted-foreground">/ {{ formatNumber(planet.resources.deuterium) }}</span>
</span>
</div>
<div v-if="Object.values(selectedFleet).some(c => c > 0)" class="flex justify-between text-xs sm:text-sm">
<span class="text-muted-foreground">{{ t('fleetView.flightTime') }}:</span>
<span>{{ formatTime(getFlightTime()) }}</span>
</div>
</CardContent>
</Card>
<!-- 派遣按钮 -->
<Button @click="handleSendFleet" :disabled="!canSendFleet()" class="w-full" size="lg">{{ t('fleetView.sendFleet') }}</Button>
</div>
<!-- 飞行任务 -->
<div v-if="activeTab === 'missions'" class="space-y-4">
<Card v-if="gameStore.player.fleetMissions.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('fleetView.noFlightMissions') }}</CardContent>
</Card>
<Card v-for="mission in gameStore.player.fleetMissions" :key="mission.id">
<CardHeader>
<div class="flex justify-between items-start">
<div>
<CardTitle class="text-base sm:text-lg">{{ getMissionName(mission.missionType) }}</CardTitle>
<CardDescription class="text-xs sm:text-sm">
{{ getPlanetName(mission.originPlanetId) }} [{{ mission.targetPosition.galaxy }}:{{ mission.targetPosition.system }}:{{
mission.targetPosition.position
}}]
</CardDescription>
</div>
<Badge :variant="mission.status === 'outbound' ? 'default' : 'secondary'">
{{ mission.status === 'outbound' ? t('fleetView.outbound') : t('fleetView.returning') }}
</Badge>
</div>
</CardHeader>
<CardContent class="space-y-3">
<!-- 舰队组成 -->
<div>
<p class="text-xs sm:text-sm font-medium mb-2">{{ t('fleetView.fleetComposition') }}:</p>
<div class="flex flex-wrap gap-2">
<Badge v-for="(count, shipType) in mission.fleet" :key="shipType" variant="outline">
{{ SHIPS[shipType].name }}: {{ count }}
<div v-if="Object.values(selectedFleet).some(c => c > 0)" class="flex justify-between text-xs sm:text-sm">
<span class="text-muted-foreground">{{ t('fleetView.flightTime') }}:</span>
<span>{{ formatTime(getFlightTime()) }}</span>
</div>
</CardContent>
</Card>
<!-- 派遣按钮 -->
<Button @click="handleSendFleet" :disabled="!canSendFleet()" class="w-full" size="lg">{{ t('fleetView.sendFleet') }}</Button>
</TabsContent>
<!-- 飞行任务 -->
<TabsContent value="missions" class="mt-4 space-y-4">
<Card v-if="gameStore.player.fleetMissions.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('fleetView.noFlightMissions') }}</CardContent>
</Card>
<Card v-for="mission in gameStore.player.fleetMissions" :key="mission.id">
<CardHeader>
<div class="flex justify-between items-start">
<div>
<CardTitle class="text-base sm:text-lg">{{ getMissionName(mission.missionType) }}</CardTitle>
<CardDescription class="text-xs sm:text-sm">
{{ getPlanetName(mission.originPlanetId) }} [{{ mission.targetPosition.galaxy }}:{{ mission.targetPosition.system }}:{{
mission.targetPosition.position
}}]
</CardDescription>
</div>
<Badge :variant="mission.status === 'outbound' ? 'default' : 'secondary'">
{{ mission.status === 'outbound' ? t('fleetView.outbound') : t('fleetView.returning') }}
</Badge>
</div>
</div>
<!-- 携带资源 -->
<div v-if="mission.cargo.metal > 0 || mission.cargo.crystal > 0 || mission.cargo.deuterium > 0 || mission.cargo.darkMatter > 0">
<p class="text-xs sm:text-sm font-medium mb-2">{{ t('fleetView.carryingResources') }}:</p>
<div class="flex flex-wrap gap-2 text-xs">
<span v-if="mission.cargo.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(mission.cargo.metal) }}
</span>
<span v-if="mission.cargo.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(mission.cargo.crystal) }}
</span>
<span v-if="mission.cargo.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(mission.cargo.deuterium) }}
</span>
<span v-if="mission.cargo.darkMatter > 0" class="flex items-center gap-1">
<ResourceIcon type="darkMatter" size="sm" />
{{ formatNumber(mission.cargo.darkMatter) }}
</span>
</CardHeader>
<CardContent class="space-y-3">
<!-- 舰队组成 -->
<div>
<p class="text-xs sm:text-sm font-medium mb-2">{{ t('fleetView.fleetComposition') }}:</p>
<div class="flex flex-wrap gap-2">
<Badge v-for="(count, shipType) in mission.fleet" :key="shipType" variant="outline">
{{ SHIPS[shipType].name }}: {{ count }}
</Badge>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="space-y-2">
<div class="flex justify-between text-xs sm:text-sm">
<span>{{ mission.status === 'outbound' ? t('fleetView.arrivalTime') : t('fleetView.returnTime') }}:</span>
<span>{{ formatTime(getRemainingTime(mission)) }}</span>
<!-- 携带资源 -->
<div v-if="mission.cargo.metal > 0 || mission.cargo.crystal > 0 || mission.cargo.deuterium > 0 || mission.cargo.darkMatter > 0">
<p class="text-xs sm:text-sm font-medium mb-2">{{ t('fleetView.carryingResources') }}:</p>
<div class="flex flex-wrap gap-2 text-xs">
<span v-if="mission.cargo.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(mission.cargo.metal) }}
</span>
<span v-if="mission.cargo.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(mission.cargo.crystal) }}
</span>
<span v-if="mission.cargo.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(mission.cargo.deuterium) }}
</span>
<span v-if="mission.cargo.darkMatter > 0" class="flex items-center gap-1">
<ResourceIcon type="darkMatter" size="sm" />
{{ formatNumber(mission.cargo.darkMatter) }}
</span>
</div>
</div>
<Progress :model-value="getMissionProgress(mission)" />
</div>
<!-- 操作 -->
<div class="flex gap-2">
<Button v-if="mission.status === 'outbound'" @click="handleRecallFleet(mission.id)" variant="outline" size="sm" class="w-full">
{{ t('fleetView.recallFleet') }}
</Button>
</div>
</CardContent>
</Card>
</div>
<!-- 进度条 -->
<div class="space-y-2">
<div class="flex justify-between text-xs sm:text-sm">
<span>{{ mission.status === 'outbound' ? t('fleetView.arrivalTime') : t('fleetView.returnTime') }}:</span>
<span>{{ formatTime(getRemainingTime(mission)) }}</span>
</div>
<Progress :model-value="getMissionProgress(mission)" />
</div>
<!-- 操作 -->
<div class="flex gap-2">
<Button
v-if="mission.status === 'outbound'"
@click="handleRecallFleet(mission.id)"
variant="outline"
size="sm"
class="w-full"
>
{{ t('fleetView.recallFleet') }}
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<!-- 提示对话框 -->
<AlertDialog ref="alertDialog" />
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { computed, ref, onMounted, onUnmounted } from 'vue'
@@ -304,30 +337,46 @@
import { ShipType, MissionType, BuildingType } from '@/types/game'
import type { Fleet, Resources } from '@/types/game'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox'
import ResourceIcon from '@/components/ResourceIcon.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import UnlockRequirement from '@/components/UnlockRequirement.vue'
import { Sword, Package, Rocket as RocketIcon, Eye, Users, Recycle, Skull } from 'lucide-vue-next'
import { Sword, Package, Rocket as RocketIcon, Eye, Users, Recycle, Skull, Gift } from 'lucide-vue-next'
import { formatNumber, formatTime } from '@/utils/format'
import * as shipValidation from '@/logic/shipValidation'
import * as fleetLogic from '@/logic/fleetLogic'
import * as shipLogic from '@/logic/shipLogic'
import * as officerLogic from '@/logic/officerLogic'
import * as publicLogic from '@/logic/publicLogic'
import * as diplomaticLogic from '@/logic/diplomaticLogic'
const route = useRoute()
const router = useRouter()
const gameStore = useGameStore()
const universeStore = useUniverseStore()
const npcStore = useNPCStore()
const { t } = useI18n()
const { SHIPS } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
// 当前时间(响应式)
const currentTime = ref(Date.now())
@@ -372,7 +421,7 @@
currentTime.value = Date.now()
}, 1000) // 每秒更新一次
const { galaxy, system, position, mission } = route.query
const { galaxy, system, position, mission, gift } = route.query
// 如果有参数,填充数据
if (galaxy || system || position) {
@@ -388,6 +437,10 @@
selectedMission.value = MissionType.Attack
} else if (mission === 'colonize') {
selectedMission.value = MissionType.Colonize
} else if (gift === '1') {
// 如果有gift参数设置为运输任务并启用赠送模式
selectedMission.value = MissionType.Transport
isGiftMode.value = true
}
// 自动切换到派遣舰队标签
@@ -405,6 +458,26 @@
}
})
// 检查目标是否为NPC星球
const targetNpc = computed(() => {
return npcStore.npcs.find(npc =>
npc.planets.some(
p =>
p.position.galaxy === targetPosition.value.galaxy &&
p.position.system === targetPosition.value.system &&
p.position.position === targetPosition.value.position
)
)
})
// 是否为赠送模式
const isGiftMode = ref(false)
// 计算赠送的预估好感度增加值
const calculateGiftReputation = (): number => {
return diplomaticLogic.calculateGiftReputationGain(cargo.value)
}
// 可用任务类型
const availableMissions = computed(() => [
{ type: MissionType.Attack, name: t('fleetView.attackMission'), icon: Sword },
@@ -467,7 +540,8 @@
if (!hasShips) return { valid: false, errorKey: 'fleetView.noShipsSelected' }
// 检查是否派遣到自己的星球
if (planet.value) {
// 回收任务和部署任务除外(回收残骸可能在同位置,部署可能到自己的月球)
if (planet.value && selectedMission.value !== MissionType.Recycle && selectedMission.value !== MissionType.Deploy) {
const isSamePlanet =
targetPosition.value.galaxy === planet.value.position.galaxy &&
targetPosition.value.system === planet.value.position.system &&
@@ -541,6 +615,13 @@
cargo,
flightTime
)
// 如果是赠送模式,标记任务
if (missionType === MissionType.Transport && isGiftMode.value && targetNpc.value) {
mission.isGift = true
mission.giftTargetNpcId = targetNpc.value.id
}
gameStore.player.fleetMissions.push(mission)
return true
}
@@ -552,10 +633,9 @@
// 验证是否可以派遣
const validation = canSendFleet()
if (!validation.valid) {
alertDialog.value?.show({
title: t('fleetView.sendFailed'),
message: validation.errorKey ? t(validation.errorKey) : t('fleetView.sendFailedMessage')
})
alertDialogTitle.value = t('fleetView.sendFailed')
alertDialogMessage.value = validation.errorKey ? t(validation.errorKey) : t('fleetView.sendFailedMessage')
alertDialogOpen.value = true
return
}
@@ -580,12 +660,12 @@
selectedFleet.value[key as ShipType] = 0
})
cargo.value = { metal: 0, crystal: 0, deuterium: 0, darkMatter: 0, energy: 0 }
isGiftMode.value = false
activeTab.value = 'missions'
} else {
alertDialog.value?.show({
title: t('fleetView.sendFailed'),
message: t('fleetView.sendFailedMessage')
})
alertDialogTitle.value = t('fleetView.sendFailed')
alertDialogMessage.value = t('fleetView.sendFailedMessage')
alertDialogOpen.value = true
}
}
@@ -599,10 +679,9 @@
const handleRecallFleet = (missionId: string) => {
const success = recallFleet(missionId)
if (!success) {
alertDialog.value?.show({
title: t('fleetView.recallFailed'),
message: t('fleetView.recallFailedMessage')
})
alertDialogTitle.value = t('fleetView.recallFailed')
alertDialogMessage.value = t('fleetView.recallFailedMessage')
alertDialogOpen.value = true
}
}

View File

@@ -26,23 +26,14 @@
<!-- 标签切换 -->
<div v-if="selectedPlanet" class="flex flex-wrap gap-2 border-b">
<Button @click="activeTab = 'resources'" :variant="activeTab === 'resources' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.resources') }}
</Button>
<Button @click="activeTab = 'buildings'" :variant="activeTab === 'buildings' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.buildings') }}
</Button>
<Button @click="activeTab = 'research'" :variant="activeTab === 'research' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.research') }}
</Button>
<Button @click="activeTab = 'ships'" :variant="activeTab === 'ships' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.ships') }}
</Button>
<Button @click="activeTab = 'defense'" :variant="activeTab === 'defense' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.defense') }}
</Button>
<Button @click="activeTab = 'officers'" :variant="activeTab === 'officers' ? 'default' : 'ghost'" class="rounded-b-none">
{{ t('gmView.officers') }}
<Button
v-for="tab in tabs"
:key="tab.value"
@click="activeTab = tab.value"
:variant="activeTab === tab.value ? 'default' : 'ghost'"
class="rounded-b-none"
>
{{ t(tab.label) }}
</Button>
</div>
@@ -177,6 +168,62 @@
</Card>
</div>
<!-- NPC测试 -->
<Card class="border-primary">
<CardHeader>
<CardTitle>{{ t('gmView.npcTesting') || 'NPC Testing' }}</CardTitle>
<CardDescription>{{ t('gmView.npcTestingDesc') || 'Test NPC spy and attack behavior' }}</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
<div class="space-y-2">
<Label>{{ t('gmView.selectNPC') || 'Select NPC' }}</Label>
<Select v-model="selectedNPCId">
<SelectTrigger>
<SelectValue :placeholder="t('gmView.chooseNPC') || 'Choose NPC'" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="npc in npcStore.npcs" :key="npc.id" :value="npc.id">{{ npc.name }} ({{ npc.difficulty }})</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>{{ t('gmView.targetPlanet') || 'Target Planet' }}</Label>
<Select v-model="targetPlanetIndex">
<SelectTrigger>
<SelectValue :placeholder="t('gmView.chooseTarget') || 'Choose Target Planet'" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="(planet, index) in gameStore.player.planets" :key="planet.id" :value="index.toString()">
{{ planet.name }} ({{ planet.position.galaxy }}:{{ planet.position.system }}:{{ planet.position.position }})
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-2">
<Button @click="testNPCSpy" variant="outline" class="w-full" :disabled="!selectedNPC">
{{ t('gmView.testSpy') || 'Test Spy' }}
</Button>
<Button @click="testNPCAttack" variant="outline" class="w-full" :disabled="!selectedNPC">
{{ t('gmView.testAttack') || 'Test Attack' }}
</Button>
</div>
<Button @click="testNPCSpyAndAttack" variant="default" class="w-full" :disabled="!selectedNPC">
{{ t('gmView.testSpyAndAttack') || 'Test Spy & Attack' }}
</Button>
<Button @click="initializeNPCFleet" variant="secondary" class="w-full" :disabled="!selectedNPC">
{{ t('gmView.initializeFleet') || 'Initialize NPC Fleet' }}
</Button>
<Button @click="accelerateAllMissions" variant="secondary" class="w-full" :disabled="!selectedNPC">
{{ t('gmView.accelerateMissions') || 'Accelerate All Missions (5s)' }}
</Button>
</CardContent>
</Card>
<!-- 危险操作 -->
<Card class="border-destructive">
<CardHeader>
@@ -193,6 +240,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { useNPCStore } from '@/stores/npcStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useI18n } from '@/composables/useI18n'
import { useGameConfig } from '@/composables/useGameConfig'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -202,14 +251,19 @@
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { BuildingType, TechnologyType, ShipType, DefenseType, OfficerType } from '@/types/game'
import * as npcBehaviorLogic from '@/logic/npcBehaviorLogic'
const gameStore = useGameStore()
const npcStore = useNPCStore()
const universeStore = useUniverseStore()
const { t } = useI18n()
const { BUILDINGS, TECHNOLOGIES, SHIPS, DEFENSES, OFFICERS } = useGameConfig()
const selectedPlanetId = ref<string>(gameStore.player.planets[0]?.id || '')
const activeTab = ref<'resources' | 'buildings' | 'research' | 'ships' | 'defense' | 'officers'>('resources')
const officerDays = ref<Record<OfficerType, number>>({} as Record<OfficerType, number>)
const selectedNPCId = ref<string>(npcStore.npcs[0]?.id || '')
const targetPlanetIndex = ref<string>('0')
// 初始化军官天数显示
Object.values(OfficerType).forEach(officer => {
@@ -226,6 +280,14 @@
return gameStore.player.planets.find(p => p.id === selectedPlanetId.value)
})
const selectedNPC = computed(() => {
return npcStore.npcs.find(npc => npc.id === selectedNPCId.value)
})
const allPlanets = computed(() => {
return [...gameStore.player.planets, ...Object.values(universeStore.planets)]
})
const resourceTypes = ['metal', 'crystal', 'deuterium', 'darkMatter'] as const
const buildingTypes = Object.values(BuildingType)
const technologyTypes = Object.values(TechnologyType)
@@ -233,6 +295,16 @@
const defenseTypes = Object.values(DefenseType)
const officerTypes = Object.values(OfficerType)
// Tab配置
const tabs = [
{ value: 'resources' as const, label: 'gmView.resources' },
{ value: 'buildings' as const, label: 'gmView.buildings' },
{ value: 'research' as const, label: 'gmView.research' },
{ value: 'ships' as const, label: 'gmView.ships' },
{ value: 'defense' as const, label: 'gmView.defense' },
{ value: 'officers' as const, label: 'gmView.officers' }
]
const setResourceAmount = (resource: string, amount: number) => {
if (selectedPlanet.value) {
selectedPlanet.value.resources[resource as keyof typeof selectedPlanet.value.resources] += amount
@@ -288,4 +360,113 @@
location.reload()
}
}
// NPC测试函数
const testNPCSpy = () => {
if (!selectedNPC.value) {
alert(t('gmView.selectNPCFirst') || 'Please select an NPC first')
return
}
const mission = npcBehaviorLogic.forceNPCSpyPlayer(
selectedNPC.value,
gameStore.player,
allPlanets.value,
parseInt(targetPlanetIndex.value)
)
if (mission) {
// 加速任务到5秒后到达
npcBehaviorLogic.accelerateNPCMission(selectedNPC.value, mission.id, 5)
alert(`${selectedNPC.value.name} will spy in 5 seconds`)
} else {
alert(t('gmView.npcNoProbes') || 'NPC does not have spy probes')
}
}
const testNPCAttack = () => {
if (!selectedNPC.value) {
alert(t('gmView.selectNPCFirst') || 'Please select an NPC first')
return
}
const mission = npcBehaviorLogic.forceNPCAttackPlayer(
selectedNPC.value,
gameStore.player,
allPlanets.value,
parseInt(targetPlanetIndex.value)
)
if (mission) {
// 加速任务到5秒后到达
npcBehaviorLogic.accelerateNPCMission(selectedNPC.value, mission.id, 5)
alert(`${selectedNPC.value.name} will attack in 5 seconds`)
} else {
alert(t('gmView.npcNoSpyReport') || 'NPC needs to spy first')
}
}
const testNPCSpyAndAttack = () => {
if (!selectedNPC.value) {
alert(t('gmView.selectNPCFirst') || 'Please select an NPC first')
return
}
const { spyMission, attackMission } = npcBehaviorLogic.forceNPCSpyAndAttack(
selectedNPC.value,
gameStore.player,
allPlanets.value,
parseInt(targetPlanetIndex.value)
)
if (spyMission && attackMission) {
// 加速任务侦查5秒后到达攻击10秒后到达
npcBehaviorLogic.accelerateNPCMission(selectedNPC.value, spyMission.id, 5)
npcBehaviorLogic.accelerateNPCMission(selectedNPC.value, attackMission.id, 10)
alert(`${selectedNPC.value.name} will spy in 5s and attack in 10s`)
} else {
alert(t('gmView.npcMissionFailed') || 'Failed to create missions')
}
}
const accelerateAllMissions = () => {
if (!selectedNPC.value) {
alert(t('gmView.selectNPCFirst') || 'Please select an NPC first')
return
}
const count = npcBehaviorLogic.accelerateAllNPCMissions(selectedNPC.value, 5)
alert(`Accelerated ${count} missions to 5 seconds`)
}
// 初始化NPC舰队
const initializeNPCFleet = () => {
if (!selectedNPC.value) {
alert(t('gmView.selectNPCFirst') || 'Please select an NPC first')
return
}
// 给NPC的第一个星球添加基础舰队
const npcPlanet = selectedNPC.value.planets[0]
if (!npcPlanet) {
alert('NPC has no planets')
return
}
// 添加间谍探测器
npcPlanet.fleet[ShipType.EspionageProbe] = (npcPlanet.fleet[ShipType.EspionageProbe] || 0) + 100
// 添加战斗舰船
npcPlanet.fleet[ShipType.LightFighter] = (npcPlanet.fleet[ShipType.LightFighter] || 0) + 500
npcPlanet.fleet[ShipType.HeavyFighter] = (npcPlanet.fleet[ShipType.HeavyFighter] || 0) + 300
npcPlanet.fleet[ShipType.Cruiser] = (npcPlanet.fleet[ShipType.Cruiser] || 0) + 200
npcPlanet.fleet[ShipType.Battleship] = (npcPlanet.fleet[ShipType.Battleship] || 0) + 100
npcPlanet.fleet[ShipType.Bomber] = (npcPlanet.fleet[ShipType.Bomber] || 0) + 50
npcPlanet.fleet[ShipType.Destroyer] = (npcPlanet.fleet[ShipType.Destroyer] || 0) + 30
npcPlanet.fleet[ShipType.Battlecruiser] = (npcPlanet.fleet[ShipType.Battlecruiser] || 0) + 20
alert(
`${selectedNPC.value.name} fleet initialized:\n- 100 Spy Probes\n- 500 Light Fighters\n- 300 Heavy Fighters\n- 200 Cruisers\n- 100 Battleships\n- 50 Bombers\n- 30 Destroyers\n- 20 Battlecruisers`
)
}
</script>

View File

@@ -8,12 +8,18 @@
<CardTitle>{{ t('galaxyView.selectCoordinates') }}</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
<div
:class="[
'grid gap-3 sm:gap-4',
highlightedNpc ? 'grid-cols-2 sm:grid-cols-4' : isInHomePlanetSystem ? 'grid-cols-2' : 'grid-cols-2 sm:grid-cols-3'
]"
>
<div class="space-y-2">
<Label for="select-galaxy" class="text-xs sm:text-sm">{{ t('galaxyView.galaxy') }}</Label>
<Select
:key="gameStore.locale"
:model-value="String(selectedGalaxy)"
:modal="false"
@update:model-value="
val => {
selectedGalaxy = Number(val)
@@ -24,7 +30,7 @@
<SelectTrigger id="select-galaxy" class="w-full">
<SelectValue :placeholder="t('galaxyView.selectGalaxy')" />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper">
<SelectItem v-for="g in 9" :key="g" :value="String(g)">{{ t('galaxyView.galaxy') }} {{ g }}</SelectItem>
</SelectContent>
</Select>
@@ -34,6 +40,7 @@
<Select
:key="`${gameStore.locale}-system`"
:model-value="String(selectedSystem)"
:modal="false"
@update:model-value="
val => {
selectedSystem = Number(val)
@@ -44,16 +51,101 @@
<SelectTrigger id="select-system" class="w-full">
<SelectValue :placeholder="t('galaxyView.selectSystem')" />
</SelectTrigger>
<SelectContent>
<SelectContent position="popper">
<SelectItem v-for="s in 10" :key="s" :value="String(s)">{{ t('galaxyView.system') }} {{ s }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="col-span-2 sm:col-span-1 flex items-end">
<Button @click="goToCurrentPlanet" variant="outline" class="w-full">
<Home class="h-4 w-4 mr-2" />
{{ t('galaxyView.myPlanet') }}
</Button>
<div v-if="!isInHomePlanetSystem" :class="highlightedNpc ? '' : 'col-span-2 sm:col-span-1'" class="space-y-2">
<Label class="text-xs sm:text-sm opacity-0">{{ t('galaxyView.myPlanets') }}</Label>
<!-- 不在母星星系时显示Popover选择 -->
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full">
<Home class="h-4 w-4 mr-2" />
{{ t('galaxyView.myPlanets') }}
</Button>
</PopoverTrigger>
<PopoverContent class="w-72 p-2" align="start">
<div class="space-y-1">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{{ t('galaxyView.selectPlanetToView') }}
</div>
<Button
v-for="p in myPlanets"
:key="p.id"
@click="goToPlanet(p)"
:disabled="p.position.galaxy === currentGalaxy && p.position.system === currentSystem"
variant="ghost"
:class="[
'w-full justify-start h-auto py-2 px-2 text-left',
p.position.galaxy === currentGalaxy &&
p.position.system === currentSystem &&
'bg-blue-100 dark:bg-blue-950/50 border border-blue-400 dark:border-blue-600'
]"
size="sm"
>
<div class="flex items-start gap-2 w-full min-w-0">
<Globe class="h-4 w-4 flex-shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-0.5">
<span class="truncate font-medium text-sm">{{ p.name }}</span>
<Badge v-if="p.isMoon" variant="outline" class="text-[10px] px-1 py-0 h-4">
{{ t('planet.moon') }}
</Badge>
</div>
<div class="text-[11px] text-muted-foreground">
[{{ p.position.galaxy }}:{{ p.position.system }}:{{ p.position.position }}]
</div>
</div>
</div>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<!-- NPC星球列表 -->
<div v-if="highlightedNpc" :class="isInHomePlanetSystem ? 'col-span-2 sm:col-span-2' : ''" class="space-y-2">
<Label class="text-xs sm:text-sm opacity-0">{{ t('galaxyView.npcPlanets') }}</Label>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" class="w-full border-yellow-400 dark:border-yellow-600">
<Globe class="h-4 w-4 mr-2" />
{{ highlightedNpc.name }} ({{ highlightedNpc.planets.length }})
</Button>
</PopoverTrigger>
<PopoverContent class="w-72 p-2" align="start">
<div class="space-y-1">
<div class="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
{{ t('galaxyView.selectPlanetToView') }}
</div>
<Button
v-for="p in highlightedNpc.planets"
:key="p.id"
@click="goToPlanet(p)"
:disabled="p.position.galaxy === currentGalaxy && p.position.system === currentSystem"
variant="ghost"
:class="[
'w-full justify-start h-auto py-2 px-2 text-left',
p.position.galaxy === currentGalaxy &&
p.position.system === currentSystem &&
'bg-yellow-100 dark:bg-yellow-950/50 border border-yellow-400 dark:border-yellow-600'
]"
size="sm"
>
<div class="flex items-start gap-2 w-full min-w-0">
<Globe class="h-4 w-4 flex-shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<div class="truncate font-medium text-sm mb-0.5">{{ p.name }}</div>
<div class="text-[11px] text-muted-foreground">
[{{ p.position.galaxy }}:{{ p.position.system }}:{{ p.position.position }}]
</div>
</div>
</div>
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</CardContent>
@@ -72,8 +164,32 @@
:key="slot.position"
class="flex items-center gap-2 sm:gap-4 p-2 sm:p-3 border rounded-lg hover:bg-muted/50 transition-colors"
:class="{
'bg-blue-50 dark:bg-blue-950 border-blue-300 dark:border-blue-700': isMyPlanet(slot.planet),
'bg-muted/30': !slot.planet
// 空位置
'bg-muted/30': !slot.planet,
// 我的星球 - 蓝色
'bg-blue-50 dark:bg-blue-950 border-blue-300 dark:border-blue-700': slot.planet && isMyPlanet(slot.planet),
// 高亮NPC - 黄色
'bg-yellow-50 dark:bg-yellow-950/30 border-yellow-400 dark:border-yellow-600 ring-2 ring-yellow-400 dark:ring-yellow-500':
slot.planet && isHighlightedNpcPlanet(slot.planet) && !isMyPlanet(slot.planet),
// 友好NPC - 绿色
'bg-green-50 dark:bg-green-950/30 border-green-300 dark:border-green-700':
slot.planet &&
!isMyPlanet(slot.planet) &&
!isHighlightedNpcPlanet(slot.planet) &&
getRelation(slot.planet)?.status === RelationStatus.Friendly,
// 敌对NPC - 红色
'bg-red-50 dark:bg-red-950/30 border-red-300 dark:border-red-700':
slot.planet &&
!isMyPlanet(slot.planet) &&
!isHighlightedNpcPlanet(slot.planet) &&
getRelation(slot.planet)?.status === RelationStatus.Hostile,
// 中立NPC - 灰色
'bg-gray-50 dark:bg-gray-950/30 border-gray-300 dark:border-gray-700':
slot.planet &&
!isMyPlanet(slot.planet) &&
!isHighlightedNpcPlanet(slot.planet) &&
getPlanetNPC(slot.planet) &&
(!getRelation(slot.planet) || getRelation(slot.planet)?.status === RelationStatus.Neutral)
}"
>
<!-- 位置编号 -->
@@ -84,32 +200,104 @@
<!-- 星球信息 -->
<div class="flex-1 min-w-0">
<div v-if="slot.planet" class="space-y-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-sm sm:text-base truncate">{{ slot.planet.name }}</h3>
<Badge v-if="isMyPlanet(slot.planet)" variant="default" class="text-xs">{{ t('galaxyView.mine') }}</Badge>
<Badge v-else variant="secondary" class="text-xs">{{ t('galaxyView.hostile') }}</Badge>
<!-- 移动端:垂直布局 / PC端水平布局 -->
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
<!-- 星球名称和坐标 -->
<div class="flex items-baseline gap-1.5 min-w-0">
<h3 class="font-semibold text-sm sm:text-base truncate">{{ slot.planet.name }}</h3>
<span class="text-xs text-muted-foreground whitespace-nowrap flex-shrink-0 sm:hidden">
[{{ slot.planet.position.galaxy }}:{{ slot.planet.position.system }}:{{ slot.planet.position.position }}]
</span>
</div>
<!-- 徽章组 -->
<div class="flex items-center gap-2 flex-wrap">
<Badge v-if="isMyPlanet(slot.planet)" variant="default" class="text-xs">{{ t('galaxyView.mine') }}</Badge>
<Badge v-else :variant="getRelationBadgeVariant(slot.planet)" class="text-xs">
{{ getRelationStatusText(slot.planet) }}
</Badge>
<!-- 残骸场徽章 - 紧凑显示 -->
<Popover v-if="getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)">
<PopoverTrigger as-child>
<Badge
variant="outline"
class="text-xs cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/30 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400 gap-1"
>
<Recycle class="h-3 w-3" />
<span class="hidden sm:inline">{{ t('galaxyView.debris') }}</span>
</Badge>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" side="top" align="start">
<div class="space-y-2">
<p class="text-xs font-semibold text-amber-700 dark:text-amber-400">{{ t('galaxyView.debrisField') }}</p>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">
{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.metal) }}
</span>
</div>
<div class="flex items-center gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">
{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.crystal) }}
</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
<p class="text-xs text-muted-foreground">
<!-- PC端显示坐标 -->
<p class="text-xs text-muted-foreground hidden sm:block">
[{{ slot.planet.position.galaxy }}:{{ slot.planet.position.system }}:{{ slot.planet.position.position }}]
</p>
<!-- 好感度显示仅NPC星球 -->
<div v-if="!isMyPlanet(slot.planet) && getReputationValue(slot.planet) !== null" class="text-xs">
<span class="text-muted-foreground">{{ t('diplomacy.reputation') }}:</span>
<span class="ml-1 font-semibold" :class="getReputationColor(getReputationValue(slot.planet))">
{{ getReputationValue(slot.planet)! > 0 ? '+' : '' }}{{ getReputationValue(slot.planet) }}
</span>
</div>
</div>
<div v-else class="text-sm text-muted-foreground">{{ t('galaxyView.emptySlot') }}</div>
<!-- 残骸场信息 -->
<div v-if="getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)" class="mt-2 p-2 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded text-xs">
<div class="flex items-center gap-2 text-amber-700 dark:text-amber-400 font-medium mb-1">
<span>{{ t('galaxyView.debrisField') }}</span>
</div>
<div class="flex gap-3 text-xs">
<span class="flex items-center gap-1">
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.metal) }}</span>
</span>
<span class="flex items-center gap-1">
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.crystal) }}</span>
</span>
</div>
<!-- 空位置 -->
<div v-else class="space-y-1">
<div class="text-sm text-muted-foreground">{{ t('galaxyView.emptySlot') }}</div>
<!-- 残骸场徽章 - 空位置时也显示 -->
<Popover v-if="getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)">
<PopoverTrigger as-child>
<Badge
variant="outline"
class="text-xs cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/30 border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400 gap-1 inline-flex"
>
<Recycle class="h-3 w-3" />
<span>{{ t('galaxyView.debris') }}</span>
</Badge>
</PopoverTrigger>
<PopoverContent class="w-auto p-3" side="top" align="start">
<div class="space-y-2">
<p class="text-xs font-semibold text-amber-700 dark:text-amber-400">{{ t('galaxyView.debrisField') }}</p>
<div class="space-y-1 text-xs">
<div class="flex items-center gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-muted-foreground">{{ t('resources.metal') }}:</span>
<span class="font-medium">
{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.metal) }}
</span>
</div>
<div class="flex items-center gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-muted-foreground">{{ t('resources.crystal') }}:</span>
<span class="font-medium">
{{ formatNumber(getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)!.resources.crystal) }}
</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
@@ -136,6 +324,16 @@
<p>{{ t('galaxyView.attack') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="slot.planet && !isMyPlanet(slot.planet) && getPlanetNPC(slot.planet)">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(slot.planet, 'gift')" variant="outline" size="sm" class="h-8 w-8 p-0">
<Gift class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ t('galaxyView.sendGift') }}</p>
</TooltipContent>
</Tooltip>
<Tooltip v-if="!slot.planet">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(null, 'colonize', slot.position)" variant="outline" size="sm" class="h-8 w-8 p-0">
@@ -158,7 +356,12 @@
</Tooltip>
<Tooltip v-if="getDebrisFieldAt(currentGalaxy, currentSystem, slot.position)">
<TooltipTrigger as-child>
<Button @click="showPlanetActions(slot.planet, 'recycle', slot.position)" variant="outline" size="sm" class="h-8 w-8 p-0">
<Button
@click="showPlanetActions(slot.planet, 'recycle', slot.position)"
variant="outline"
size="sm"
class="h-8 w-8 p-0"
>
<Recycle class="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</TooltipTrigger>
@@ -174,44 +377,122 @@
</Card>
<!-- 快速派遣对话框 -->
<AlertDialog ref="actionDialog" />
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleAlertDialogConfirm">{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
import { useUniverseStore } from '@/stores/universeStore'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import type { Planet, DebrisField } from '@/types/game'
import { RelationStatus } from '@/types/game'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import AlertDialog from '@/components/AlertDialog.vue'
import { Home, Eye, Sword, Rocket, Recycle } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import ResourceIcon from '@/components/ResourceIcon.vue'
import { Home, Eye, Sword, Rocket, Recycle, Gift, Globe } from 'lucide-vue-next'
import { useRouter, useRoute } from 'vue-router'
import * as gameLogic from '@/logic/gameLogic'
import { formatNumber } from '@/utils/format'
const gameStore = useGameStore()
const universeStore = useUniverseStore()
const npcStore = useNPCStore()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const actionDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
const alertDialogConfirmAction = ref<(() => void) | null>(null)
const selectedGalaxy = ref(1)
const selectedSystem = ref(1)
const currentGalaxy = ref(1)
const currentSystem = ref(1)
// 保存要高亮的NPC ID从URL参数初始化之后即使URL清除也保持
const highlightNpcId = ref<string | undefined>(undefined)
// 获取要高亮的NPC对象
const highlightedNpc = computed(() => {
if (!highlightNpcId.value) return null
return npcStore.npcs.find(n => n.id === highlightNpcId.value) || null
})
const systemSlots = ref<Array<{ position: number; planet: Planet | null }>>([])
// 获取玩家的母星
const homePlanet = computed(() => {
// 第一个非月球星球就是母星
return gameStore.player.planets.find(p => !p.isMoon)
})
// 获取玩家所有非月球星球
const myPlanets = computed(() => {
return gameStore.player.planets.filter(p => !p.isMoon)
})
// 判断当前是否在母星所在星系
const isInHomePlanetSystem = computed(() => {
if (!homePlanet.value) return false
return currentGalaxy.value === homePlanet.value.position.galaxy && currentSystem.value === homePlanet.value.position.system
})
onMounted(() => {
// 默认显示当前星球所在的星系
if (gameStore.currentPlanet) {
// 从URL参数中读取并保存高亮NPC ID
if (route.query.highlightNpc) {
highlightNpcId.value = route.query.highlightNpc as string
}
// 优先检查URL参数中的星系坐标
const queryGalaxy = route.query.galaxy ? Number(route.query.galaxy) : null
const querySystem = route.query.system ? Number(route.query.system) : null
if (queryGalaxy && querySystem) {
// 如果URL中有坐标参数跳转到指定星系
currentGalaxy.value = queryGalaxy
currentSystem.value = querySystem
selectedGalaxy.value = queryGalaxy
selectedSystem.value = querySystem
loadSystem()
// 立即清除URL参数但保持本地变量中的highlightNpcId
clearUrlParams()
} else if (gameStore.currentPlanet) {
// 否则默认显示当前星球所在的星系
currentGalaxy.value = gameStore.currentPlanet.position.galaxy
currentSystem.value = gameStore.currentPlanet.position.system
selectedGalaxy.value = currentGalaxy.value
@@ -225,11 +506,12 @@
return positions.map(pos => {
const key = gameLogic.generatePositionKey(galaxy, system, pos.position)
// 先从玩家星球中查找,再从宇宙地图中查找
const planet = gameStore.player.planets.find(p =>
p.position.galaxy === galaxy &&
p.position.system === system &&
p.position.position === pos.position
) || universeStore.planets[key] || null
const planet =
gameStore.player.planets.find(
p => p.position.galaxy === galaxy && p.position.system === system && p.position.position === pos.position
) ||
universeStore.planets[key] ||
null
return { position: pos.position, planet }
})
}
@@ -240,6 +522,13 @@
return universeStore.debrisFields[debrisId] || null
}
// 清除URL参数
const clearUrlParams = () => {
if (route.query.highlightNpc || route.query.galaxy || route.query.system) {
router.replace({ query: {} })
}
}
// 加载星系
const loadSystem = () => {
currentGalaxy.value = selectedGalaxy.value
@@ -247,15 +536,13 @@
systemSlots.value = getSystemPlanets(currentGalaxy.value, currentSystem.value)
}
// 跳转到当前星球
const goToCurrentPlanet = () => {
if (gameStore.currentPlanet) {
currentGalaxy.value = gameStore.currentPlanet.position.galaxy
currentSystem.value = gameStore.currentPlanet.position.system
selectedGalaxy.value = currentGalaxy.value
selectedSystem.value = currentSystem.value
loadSystem()
}
// 跳转到指定星球的星系
const goToPlanet = (planet: Planet) => {
currentGalaxy.value = planet.position.galaxy
currentSystem.value = planet.position.system
selectedGalaxy.value = currentGalaxy.value
selectedSystem.value = currentSystem.value
systemSlots.value = getSystemPlanets(currentGalaxy.value, currentSystem.value)
}
// 判断是否为我的星球
@@ -264,14 +551,95 @@
return planet.ownerId === gameStore.player.id
}
// 判断星球是否属于高亮显示的NPC
const isHighlightedNpcPlanet = (planet: Planet | null): boolean => {
if (!planet || !highlightNpcId.value) return false
const npc = npcStore.npcs.find(n => n.id === highlightNpcId.value)
if (!npc) return false
return npc.planets.some(p => p.id === planet.id)
}
// 获取星球所属的NPC
const getPlanetNPC = (planet: Planet | null) => {
if (!planet || isMyPlanet(planet)) return null
// 通过坐标匹配而不是ID因为universeStore中的星球和npcStore中的星球可能是不同的对象
return npcStore.npcs.find(npc =>
npc.planets.some(
p =>
p.position.galaxy === planet.position.galaxy &&
p.position.system === planet.position.system &&
p.position.position === planet.position.position
)
)
}
// 获取外交关系
const getRelation = (planet: Planet | null) => {
const npc = getPlanetNPC(planet)
if (!npc) return null
return gameStore.player.diplomaticRelations?.[npc.id]
}
// 获取关系状态Badge样式
const getRelationBadgeVariant = (planet: Planet | null) => {
const relation = getRelation(planet)
if (!relation) return 'secondary'
switch (relation.status) {
case RelationStatus.Friendly:
return 'default'
case RelationStatus.Hostile:
return 'destructive'
default:
return 'secondary'
}
}
// 获取关系状态文本
const getRelationStatusText = (planet: Planet | null) => {
const relation = getRelation(planet)
if (!relation) return t('diplomacy.status.neutral')
switch (relation.status) {
case RelationStatus.Friendly:
return t('diplomacy.status.friendly')
case RelationStatus.Hostile:
return t('diplomacy.status.hostile')
default:
return t('diplomacy.status.neutral')
}
}
// 获取好感度值
const getReputationValue = (planet: Planet | null): number | null => {
const relation = getRelation(planet)
return relation?.reputation ?? null
}
// 获取好感度颜色
const getReputationColor = (reputation: number | null) => {
if (reputation === null) return 'text-muted-foreground'
if (reputation >= 20) return 'text-green-600 dark:text-green-400'
if (reputation <= -20) return 'text-red-600 dark:text-red-400'
return 'text-muted-foreground'
}
// 切换到指定星球
const switchToPlanet = (planetId: string) => {
gameStore.currentPlanetId = planetId
router.push('/')
}
// AlertDialog 确认处理
const handleAlertDialogConfirm = () => {
if (alertDialogConfirmAction.value) {
alertDialogConfirmAction.value()
}
alertDialogOpen.value = false
}
// 显示星球操作
const showPlanetActions = (planet: Planet | null, action: 'spy' | 'attack' | 'colonize' | 'recycle', position?: number) => {
const showPlanetActions = (planet: Planet | null, action: 'spy' | 'attack' | 'colonize' | 'recycle' | 'gift', position?: number) => {
const targetPos = planet ? planet.position : { galaxy: currentGalaxy.value, system: currentSystem.value, position: position! }
const coordinates = `${targetPos.galaxy}:${targetPos.system}:${targetPos.position}`
@@ -289,23 +657,27 @@
} else if (action === 'recycle') {
title = t('galaxyView.recyclePlanetTitle')
message = t('galaxyView.recyclePlanetMessage').replace('{coordinates}', coordinates)
} else if (action === 'gift') {
title = t('galaxyView.giftPlanetTitle')
message = t('galaxyView.giftPlanetMessage').replace('{coordinates}', coordinates)
}
actionDialog.value?.show({
title,
message,
onConfirm: () => {
// 跳转到舰队页面并填充目标坐标
router.push({
path: '/fleet',
query: {
galaxy: targetPos.galaxy,
system: targetPos.system,
position: targetPos.position,
mission: action
}
})
}
})
// 设置对话框状态
alertDialogTitle.value = title
alertDialogMessage.value = message
alertDialogConfirmAction.value = () => {
// 跳转到舰队页面并填充目标坐标
router.push({
path: '/fleet',
query: {
galaxy: targetPos.galaxy,
system: targetPos.system,
position: targetPos.position,
mission: action === 'gift' ? undefined : action,
gift: action === 'gift' ? '1' : undefined
}
})
}
alertDialogOpen.value = true
}
</script>

View File

@@ -3,85 +3,293 @@
<h1 class="text-2xl sm:text-3xl font-bold">{{ t('messagesView.title') }}</h1>
<!-- 标签切换 -->
<div class="flex gap-2 border-b">
<Button @click="activeTab = 'battles'" :variant="activeTab === 'battles' ? 'default' : 'ghost'" class="rounded-b-none">
<Sword class="h-4 w-4 mr-2" />
{{ t('messagesView.battles') }}
<Badge v-if="unreadBattles > 0" variant="destructive" class="ml-2">{{ unreadBattles }}</Badge>
</Button>
<Button @click="activeTab = 'spy'" :variant="activeTab === 'spy' ? 'default' : 'ghost'" class="rounded-b-none">
<Eye class="h-4 w-4 mr-2" />
{{ t('messagesView.spy') }}
<Badge v-if="unreadSpyReports > 0" variant="destructive" class="ml-2">{{ unreadSpyReports }}</Badge>
</Button>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 sm:grid-cols-4" :tab-count="4">
<TabsTrigger v-for="tab in tabs" :key="tab.value" :value="tab.value" class="flex items-center justify-center gap-1 px-2">
<component :is="tab.icon" class="h-3 w-3 sm:h-4 sm:w-4" />
<span class="text-xs sm:text-sm truncate">{{ tab.label }}</span>
<Badge v-if="tab.unreadCount > 0" variant="destructive" class="hidden sm:flex ml-1">
{{ tab.unreadCount }}
</Badge>
</TabsTrigger>
</TabsList>
<!-- 战斗报告列表 -->
<div v-if="activeTab === 'battles'" class="space-y-2">
<Card v-if="gameStore.player.battleReports.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noBattleReports') }}</CardContent>
</Card>
<!-- 战斗报告列表 -->
<TabsContent value="battles" class="mt-4 space-y-2">
<Card v-if="gameStore.player.battleReports.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noBattleReports') }}</CardContent>
</Card>
<Card
v-for="report in sortedBattleReports"
:key="report.id"
@click="openBattleReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Sword class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.battleReport') }}</CardTitle>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge
:variant="report.winner === 'attacker' ? 'default' : report.winner === 'defender' ? 'destructive' : 'secondary'"
class="text-xs"
<Card
v-for="report in sortedBattleReports"
:key="report.id"
@click="openBattleReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Sword class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.battleReport') }}</CardTitle>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge :variant="getBattleResultVariant(report)" class="text-xs">
{{ getBattleResultText(report) }}
</Badge>
</div>
<Button @click.stop="deleteBattleReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
<!-- 间谍报告列表合并侦查报告 + 被侦查通知 -->
<TabsContent value="spy" class="mt-4 space-y-2">
<Card v-if="gameStore.player.spyReports.length === 0 && sortedSpiedNotifications.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noSpyReports') }}</CardContent>
</Card>
<!-- 侦查报告 -->
<Card
v-for="report in sortedSpyReports"
:key="report.id"
@click="openSpyReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Eye class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.spyReport') }}</CardTitle>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge variant="outline" class="text-xs">{{ report.targetPlanetId }}</Badge>
</div>
<Button @click.stop="deleteSpyReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
<!-- 被侦查通知 -->
<Card
v-for="notification in sortedSpiedNotifications"
:key="notification.id"
@click="openSpiedNotification(notification)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<AlertTriangle class="h-4 w-4 flex-shrink-0 text-destructive" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.spiedNotification') }}</CardTitle>
<Badge v-if="!notification.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge :variant="notification.detectionSuccess ? 'destructive' : 'secondary'" class="text-xs">
{{ notification.detectionSuccess ? t('messagesView.detected') : t('messagesView.undetected') }}
</Badge>
</div>
<Button @click.stop="deleteSpiedNotification(notification.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ notification.npcName }} {{ notification.targetPlanetName }} · {{ formatDate(notification.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
<!-- NPC相关消息活动礼物被拒绝 -->
<TabsContent value="npc" class="mt-4 space-y-2">
<Card
v-if="
sortedNPCActivityNotifications.length === 0 &&
sortedGiftNotifications.length === 0 &&
sortedGiftRejectedNotifications.length === 0
"
>
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noNPCActivity') }}</CardContent>
</Card>
<!-- NPC活动通知 -->
<Card
v-for="notification in sortedNPCActivityNotifications"
:key="notification.id"
@click="openNPCActivityNotification(notification)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Recycle class="h-4 w-4 flex-shrink-0 text-blue-500" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.npcRecycleActivity') }}</CardTitle>
<Badge v-if="!notification.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
</div>
<Button
@click.stop="deleteNPCActivityNotification(notification.id)"
variant="ghost"
size="icon"
class="h-8 w-8 flex-shrink-0"
>
{{ report.winner === 'attacker' ? t('messagesView.victory') : report.winner === 'defender' ? t('messagesView.defeat') : t('messagesView.draw') }}
</Badge>
<X class="h-4 w-4" />
</Button>
</div>
<Button @click.stop="deleteBattleReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ notification.npcName }} →
{{
notification.targetPlanetName ||
`[${notification.targetPosition.galaxy}:${notification.targetPosition.system}:${notification.targetPosition.position}]`
}}
· {{ formatDate(notification.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
<!-- 间谍报告列表 -->
<div v-if="activeTab === 'spy'" class="space-y-2">
<Card v-if="gameStore.player.spyReports.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noSpyReports') }}</CardContent>
</Card>
<Card
v-for="report in sortedSpyReports"
:key="report.id"
@click="openSpyReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Eye class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.spyReport') }}</CardTitle>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge variant="outline" class="text-xs">{{ report.targetPlanetId }}</Badge>
<!-- 礼物通知 -->
<Card
v-for="gift in sortedGiftNotifications"
:key="gift.id"
@click="markGiftAsRead(gift)"
class="hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Gift class="h-4 w-4 flex-shrink-0 text-green-600" />
<CardTitle class="text-base sm:text-lg">{{ t('messagesView.giftFrom').replace('{npcName}', gift.fromNpcName) }}</CardTitle>
<Badge v-if="!gift.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
</div>
<Button @click.stop="deleteGiftNotification(gift.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<Button @click.stop="deleteSpyReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
</div>
<CardDescription class="text-xs sm:text-sm">{{ formatDate(gift.timestamp) }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-3">
<div class="text-sm">
<div class="font-semibold mb-1">{{ t('messagesView.giftResources') }}:</div>
<div class="grid grid-cols-3 gap-2">
<div v-if="gift.resources.metal > 0">{{ t('resources.metal') }}: {{ gift.resources.metal.toLocaleString() }}</div>
<div v-if="gift.resources.crystal > 0">{{ t('resources.crystal') }}: {{ gift.resources.crystal.toLocaleString() }}</div>
<div v-if="gift.resources.deuterium > 0">
{{ t('resources.deuterium') }}: {{ gift.resources.deuterium.toLocaleString() }}
</div>
</div>
</div>
<div class="text-xs text-muted-foreground">
{{ t('messagesView.expectedReputation') }}:
<span class="text-green-600">+{{ gift.expectedReputationGain }}</span>
</div>
<div class="flex gap-2">
<Button @click.stop="acceptGift(gift)" variant="default" size="sm" class="flex-1">
<Check class="h-4 w-4 mr-1" />
{{ t('messagesView.acceptGift') }}
</Button>
<Button @click.stop="rejectGift(gift)" variant="outline" size="sm" class="flex-1">
<Ban class="h-4 w-4 mr-1" />
{{ t('messagesView.rejectGift') }}
</Button>
</div>
</div>
</CardContent>
</Card>
<!-- 礼物被拒绝通知 -->
<Card
v-for="rejection in sortedGiftRejectedNotifications"
:key="rejection.id"
@click="markGiftRejectedAsRead(rejection)"
class="hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Ban class="h-4 w-4 flex-shrink-0 text-red-600" />
<CardTitle class="text-base sm:text-lg">
{{ t('messagesView.giftRejectedBy').replace('{npcName}', rejection.npcName) }}
</CardTitle>
<Badge v-if="!rejection.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
</div>
<Button @click.stop="deleteGiftRejectedNotification(rejection.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">{{ formatDate(rejection.timestamp) }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div class="text-sm">
<div class="font-semibold mb-1">{{ t('messagesView.rejectedResources') }}:</div>
<div class="grid grid-cols-3 gap-2">
<div v-if="rejection.rejectedResources.metal > 0">
{{ t('resources.metal') }}: {{ rejection.rejectedResources.metal.toLocaleString() }}
</div>
<div v-if="rejection.rejectedResources.crystal > 0">
{{ t('resources.crystal') }}: {{ rejection.rejectedResources.crystal.toLocaleString() }}
</div>
<div v-if="rejection.rejectedResources.deuterium > 0">
{{ t('resources.deuterium') }}: {{ rejection.rejectedResources.deuterium.toLocaleString() }}
</div>
</div>
</div>
<div class="text-xs text-muted-foreground">
{{ t('messagesView.currentReputation') }}:
<span :class="rejection.currentReputation >= 0 ? 'text-green-600' : 'text-red-600'">{{ rejection.currentReputation }}</span>
</div>
<div class="text-xs text-muted-foreground">
{{ t('messagesView.rejectionReason.' + rejection.reason) }}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<!-- 任务报告列表 -->
<TabsContent value="missions" class="mt-4 space-y-2">
<Card v-if="sortedMissionReports.length === 0">
<CardContent class="py-8 text-center text-muted-foreground">{{ t('messagesView.noMissionReports') }}</CardContent>
</Card>
<Card
v-for="report in sortedMissionReports"
:key="report.id"
@click="openMissionReport(report)"
class="cursor-pointer hover:shadow-md transition-shadow"
>
<CardHeader class="pb-3">
<div class="flex justify-between items-center gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Package class="h-4 w-4 flex-shrink-0" />
<CardTitle class="text-base sm:text-lg">{{ getMissionTypeName(report.missionType) }}</CardTitle>
<Badge v-if="!report.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
<Badge :variant="report.success ? 'default' : 'destructive'" class="text-xs">
{{ report.success ? t('messagesView.success') : t('messagesView.failed') }}
</Badge>
</div>
<Button @click.stop="deleteMissionReport(report.id)" variant="ghost" size="icon" class="h-8 w-8 flex-shrink-0">
<X class="h-4 w-4" />
</Button>
</div>
<CardDescription class="text-xs sm:text-sm">
{{ report.originPlanetName }} →
{{
report.targetPlanetName ||
`[${report.targetPosition.galaxy}:${report.targetPosition.system}:${report.targetPosition.position}]`
}}
· {{ formatDate(report.timestamp) }}
</CardDescription>
</CardHeader>
</Card>
</TabsContent>
</Tabs>
<!-- 战斗报告对话框 -->
<BattleReportDialog v-model:open="showBattleDialog" :report="selectedBattleReport" />
@@ -96,17 +304,30 @@
import { useI18n } from '@/composables/useI18n'
import { computed, ref } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import BattleReportDialog from '@/components/BattleReportDialog.vue'
import SpyReportDialog from '@/components/SpyReportDialog.vue'
import { formatDate } from '@/utils/format'
import { X, Sword, Eye } from 'lucide-vue-next'
import type { BattleResult, SpyReport } from '@/types/game'
import { X, Sword, Eye, AlertTriangle, Package, Recycle, Gift, Ban, Check, Users } from 'lucide-vue-next'
import type {
BattleResult,
SpyReport,
SpiedNotification,
NPCActivityNotification,
MissionReport,
GiftNotification,
GiftRejectedNotification
} from '@/types/game'
import { MissionType } from '@/types/game'
import { useNPCStore } from '@/stores/npcStore'
import * as diplomaticLogic from '@/logic/diplomaticLogic'
const gameStore = useGameStore()
const npcStore = useNPCStore()
const { t } = useI18n()
const activeTab = ref<'battles' | 'spy'>('battles')
const activeTab = ref<'battles' | 'spy' | 'missions' | 'npc'>('battles')
// 对话框状态
const showBattleDialog = ref(false)
@@ -124,6 +345,30 @@
return [...gameStore.player.spyReports].sort((a, b) => b.timestamp - a.timestamp)
})
// 排序后的被侦查通知(最新的在前)
const sortedSpiedNotifications = computed(() => {
if (!gameStore.player.spiedNotifications) {
return []
}
return [...gameStore.player.spiedNotifications].sort((a, b) => b.timestamp - a.timestamp)
})
// 排序后的任务报告(最新的在前)
const sortedMissionReports = computed(() => {
if (!gameStore.player.missionReports) {
return []
}
return [...gameStore.player.missionReports].sort((a, b) => b.timestamp - a.timestamp)
})
// 排序后的NPC活动通知最新的在前
const sortedNPCActivityNotifications = computed(() => {
if (!gameStore.player.npcActivityNotifications) {
return []
}
return [...gameStore.player.npcActivityNotifications].sort((a, b) => b.timestamp - a.timestamp)
})
// 未读战斗报告数量
const unreadBattles = computed(() => {
return gameStore.player.battleReports.filter(r => !r.read).length
@@ -134,6 +379,126 @@
return gameStore.player.spyReports.filter(r => !r.read).length
})
// 未读被侦查通知数量
const unreadSpiedNotifications = computed(() => {
if (!gameStore.player.spiedNotifications) {
return 0
}
return gameStore.player.spiedNotifications.filter(n => !n.read).length
})
// 未读NPC活动通知数量
const unreadNPCActivity = computed(() => {
if (!gameStore.player.npcActivityNotifications) {
return 0
}
return gameStore.player.npcActivityNotifications.filter(n => !n.read).length
})
// 未读任务报告数量
const unreadMissionReports = computed(() => {
if (!gameStore.player.missionReports) {
return 0
}
return gameStore.player.missionReports.filter(r => !r.read).length
})
// 未读礼物通知数量
const unreadGiftNotifications = computed(() => {
if (!gameStore.player.giftNotifications) {
return 0
}
return gameStore.player.giftNotifications.filter(n => !n.read).length
})
// 未读礼物被拒绝通知数量
const unreadGiftRejected = computed(() => {
if (!gameStore.player.giftRejectedNotifications) {
return 0
}
return gameStore.player.giftRejectedNotifications.filter(n => !n.read).length
})
// 合并:侦查相关未读总数(侦查报告 + 被侦查通知)
const unreadSpyTotal = computed(() => {
return unreadSpyReports.value + unreadSpiedNotifications.value
})
// 合并NPC相关未读总数NPC活动 + 礼物通知 + 礼物被拒绝)
const unreadNPCTotal = computed(() => {
return unreadNPCActivity.value + unreadGiftNotifications.value + unreadGiftRejected.value
})
// 标签页配置
const tabs = computed(() => [
{
value: 'battles',
icon: Sword,
label: t('messagesView.battles'),
unreadCount: unreadBattles.value
},
{
value: 'spy',
icon: Eye,
label: t('messagesView.spy'),
unreadCount: unreadSpyTotal.value
},
{
value: 'missions',
icon: Package,
label: t('messagesView.missions'),
unreadCount: unreadMissionReports.value
},
{
value: 'npc',
icon: Users,
label: t('messagesView.npc'),
unreadCount: unreadNPCTotal.value
}
])
// 排序后的礼物通知(最新的在前)
const sortedGiftNotifications = computed(() => {
if (!gameStore.player.giftNotifications) {
return []
}
return [...gameStore.player.giftNotifications].sort((a, b) => b.timestamp - a.timestamp)
})
// 排序后的礼物被拒绝通知(最新的在前)
const sortedGiftRejectedNotifications = computed(() => {
if (!gameStore.player.giftRejectedNotifications) {
return []
}
return [...gameStore.player.giftRejectedNotifications].sort((a, b) => b.timestamp - a.timestamp)
})
// 判断战斗结果Badge颜色
const getBattleResultVariant = (report: BattleResult): 'default' | 'destructive' | 'secondary' => {
if (report.winner === 'draw') {
return 'secondary'
}
// 判断玩家是攻击方还是防守方
const isPlayerAttacker = report.attackerId === gameStore.player.id
const playerWon = isPlayerAttacker ? report.winner === 'attacker' : report.winner === 'defender'
return playerWon ? 'default' : 'destructive'
}
// 获取战斗结果文本
const getBattleResultText = (report: BattleResult): string => {
if (report.winner === 'draw') {
return t('messagesView.draw')
}
// 判断玩家是攻击方还是防守方
const isPlayerAttacker = report.attackerId === gameStore.player.id
const playerWon = isPlayerAttacker ? report.winner === 'attacker' : report.winner === 'defender'
return playerWon ? t('messagesView.victory') : t('messagesView.defeat')
}
// 打开战斗报告
const openBattleReport = (report: BattleResult) => {
selectedBattleReport.value = report
@@ -154,6 +519,14 @@
}
}
// 打开被侦查通知
const openSpiedNotification = (notification: SpiedNotification) => {
// 标记为已读
if (!notification.read) {
notification.read = true
}
}
// 删除战斗报告
const deleteBattleReport = (reportId: string) => {
const index = gameStore.player.battleReports.findIndex(r => r.id === reportId)
@@ -169,4 +542,117 @@
gameStore.player.spyReports.splice(index, 1)
}
}
// 删除被侦查通知
const deleteSpiedNotification = (notificationId: string) => {
if (!gameStore.player.spiedNotifications) {
return
}
const index = gameStore.player.spiedNotifications.findIndex(n => n.id === notificationId)
if (index > -1) {
gameStore.player.spiedNotifications.splice(index, 1)
}
}
// 打开NPC活动通知
const openNPCActivityNotification = (notification: NPCActivityNotification) => {
// 标记为已读
if (!notification.read) {
notification.read = true
}
}
// 删除NPC活动通知
const deleteNPCActivityNotification = (notificationId: string) => {
if (!gameStore.player.npcActivityNotifications) {
return
}
const index = gameStore.player.npcActivityNotifications.findIndex(n => n.id === notificationId)
if (index > -1) {
gameStore.player.npcActivityNotifications.splice(index, 1)
}
}
// 获取任务类型名称
const getMissionTypeName = (missionType: string): string => {
const typeMap: Record<string, string> = {
[MissionType.Transport]: t('fleetView.transport'),
[MissionType.Colonize]: t('fleetView.colonize'),
[MissionType.Deploy]: t('fleetView.deploy'),
[MissionType.Recycle]: t('fleetView.recycle'),
[MissionType.Destroy]: t('fleetView.destroy')
}
return typeMap[missionType] || missionType
}
// 打开任务报告
const openMissionReport = (report: MissionReport) => {
// 标记为已读
if (!report.read) {
report.read = true
}
}
// 删除任务报告
const deleteMissionReport = (reportId: string) => {
if (!gameStore.player.missionReports) {
return
}
const index = gameStore.player.missionReports.findIndex(r => r.id === reportId)
if (index > -1) {
gameStore.player.missionReports.splice(index, 1)
}
}
// 标记礼物通知为已读
const markGiftAsRead = (gift: GiftNotification) => {
if (!gift.read) {
gift.read = true
}
}
// 接受礼物
const acceptGift = (gift: GiftNotification) => {
const npc = npcStore.npcs.find(n => n.id === gift.fromNpcId)
if (npc) {
diplomaticLogic.acceptNPCGift(gameStore.player, npc, gift)
}
}
// 拒绝礼物
const rejectGift = (gift: GiftNotification) => {
const npc = npcStore.npcs.find(n => n.id === gift.fromNpcId)
if (npc) {
diplomaticLogic.rejectNPCGift(gameStore.player, npc, gift)
}
}
// 删除礼物通知
const deleteGiftNotification = (giftId: string) => {
if (!gameStore.player.giftNotifications) {
return
}
const index = gameStore.player.giftNotifications.findIndex(g => g.id === giftId)
if (index > -1) {
gameStore.player.giftNotifications.splice(index, 1)
}
}
// 标记礼物被拒绝通知为已读
const markGiftRejectedAsRead = (rejection: GiftRejectedNotification) => {
if (!rejection.read) {
rejection.read = true
}
}
// 删除礼物被拒绝通知
const deleteGiftRejectedNotification = (rejectionId: string) => {
if (!gameStore.player.giftRejectedNotifications) {
return
}
const index = gameStore.player.giftRejectedNotifications.findIndex(r => r.id === rejectionId)
if (index > -1) {
gameStore.player.giftRejectedNotifications.splice(index, 1)
}
}
</script>

View File

@@ -5,14 +5,18 @@
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 sm:gap-6">
<Card v-for="officerType in Object.values(OfficerType)" :key="officerType">
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<CardTitle class="text-lg sm:text-xl">{{ OFFICERS[officerType].name }}</CardTitle>
<CardDescription class="text-xs sm:text-sm">{{ OFFICERS[officerType].description }}</CardDescription>
<div class="mb-2">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
<CardTitle class="text-sm sm:text-base lg:text-lg order-2 sm:order-1">{{ OFFICERS[officerType].name }}</CardTitle>
<Badge v-if="isOfficerActive(officerType)" variant="default" class="text-xs whitespace-nowrap self-start order-1 sm:order-2">
{{ t('officersView.activated') }}
</Badge>
<Badge v-else variant="outline" class="text-xs whitespace-nowrap self-start order-1 sm:order-2">
{{ t('officersView.inactive') }}
</Badge>
</div>
<Badge v-if="isOfficerActive(officerType)" variant="default" class="text-xs">{{ t('officersView.activated') }}</Badge>
<Badge v-else variant="outline" class="text-xs">{{ t('officersView.inactive') }}</Badge>
</div>
<CardDescription class="text-xs sm:text-sm">{{ OFFICERS[officerType].description }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 状态信息 -->
@@ -30,44 +34,21 @@
<div class="space-y-2">
<p class="text-sm font-medium text-muted-foreground">{{ t('officersView.recruitCost') }} (7{{ t('officersView.days') }}):</p>
<div class="space-y-1.5">
<div class="flex items-center gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="resourceType.key !== 'darkMatter' || OFFICERS[officerType].cost.darkMatter > 0"
class="flex items-center gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-sm"
:class="planet ? getResourceCostColor(planet.resources.metal, OFFICERS[officerType].cost.metal) : ''"
:class="
planet ? getResourceCostColor(planet.resources[resourceType.key], OFFICERS[officerType].cost[resourceType.key]) : ''
"
>
{{ formatNumber(OFFICERS[officerType].cost.metal) }}
</span>
</div>
<div class="flex items-center gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<span
class="font-medium text-sm"
:class="planet ? getResourceCostColor(planet.resources.crystal, OFFICERS[officerType].cost.crystal) : ''"
>
{{ formatNumber(OFFICERS[officerType].cost.crystal) }}
</span>
</div>
<div class="flex items-center gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<span
class="font-medium text-sm"
:class="planet ? getResourceCostColor(planet.resources.deuterium, OFFICERS[officerType].cost.deuterium) : ''"
>
{{ formatNumber(OFFICERS[officerType].cost.deuterium) }}
</span>
</div>
<div v-if="OFFICERS[officerType].cost.darkMatter > 0" class="flex items-center gap-2">
<ResourceIcon type="darkMatter" size="sm" />
<span class="text-xs">{{ t('resources.darkMatter') }}:</span>
<span
class="font-medium text-sm"
:class="planet ? getResourceCostColor(planet.resources.darkMatter, OFFICERS[officerType].cost.darkMatter) : ''"
>
{{ formatNumber(OFFICERS[officerType].cost.darkMatter) }}
{{ formatNumber(OFFICERS[officerType].cost[resourceType.key]) }}
</span>
</div>
</div>
@@ -121,14 +102,19 @@
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<Button v-if="!isOfficerActive(officerType)" @click="handleHire(officerType)" :disabled="!canHire(officerType)" class="flex-1">
<div class="flex flex-col sm:flex-row gap-2">
<Button v-if="!isOfficerActive(officerType)" @click="handleHire(officerType)" :disabled="!canHire(officerType)" class="w-full">
{{ t('officersView.hire') }} (7{{ t('officersView.days') }})
</Button>
<Button v-if="isOfficerActive(officerType)" @click="handleRenew(officerType)" :disabled="!canHire(officerType)" class="flex-1">
<Button
v-if="isOfficerActive(officerType)"
@click="handleRenew(officerType)"
:disabled="!canHire(officerType)"
class="w-full sm:flex-1"
>
{{ t('officersView.renew') }} (7{{ t('officersView.days') }})
</Button>
<Button v-if="isOfficerActive(officerType)" @click="handleDismiss(officerType)" variant="outline" size="sm">
<Button v-if="isOfficerActive(officerType)" @click="handleDismiss(officerType)" variant="outline" class="w-full sm:w-auto">
{{ t('officersView.dismiss') }}
</Button>
</div>
@@ -136,9 +122,36 @@
</Card>
</div>
<!-- 提示对话框 -->
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 确认对话框 -->
<AlertDialog ref="alertDialog" />
<ConfirmDialog ref="confirmDialog" />
<AlertDialog :open="confirmDialogOpen" @update:open="confirmDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ confirmDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ confirmDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{{ t('common.cancel') }}</AlertDialogCancel>
<AlertDialogAction @click="handleConfirmDialogConfirm">{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -150,8 +163,16 @@
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/ResourceIcon.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { formatNumber, formatTime, formatDate, getResourceCostColor } from '@/utils/format'
import * as officerLogic from '@/logic/officerLogic'
import * as resourceLogic from '@/logic/resourceLogic'
@@ -162,8 +183,32 @@
const { OFFICERS } = useGameConfig()
const gameStore = useGameStore()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
const confirmDialog = ref<InstanceType<typeof ConfirmDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
// ConfirmDialog 状态
const confirmDialogOpen = ref(false)
const confirmDialogTitle = ref('')
const confirmDialogMessage = ref('')
const confirmDialogAction = ref<(() => void) | null>(null)
const handleConfirmDialogConfirm = () => {
if (confirmDialogAction.value) {
confirmDialogAction.value()
}
confirmDialogOpen.value = false
}
// 资源类型配置(用于成本显示)
const costResourceTypes = [
{ key: 'metal' as const },
{ key: 'crystal' as const },
{ key: 'deuterium' as const },
{ key: 'darkMatter' as const }
]
// 检查军官是否激活
const isOfficerActive = (officerType: OfficerType): boolean => {
@@ -212,19 +257,17 @@
// 招募军官
const handleHire = (officerType: OfficerType) => {
confirmDialog.value?.show({
title: t('officersView.hireTitle'),
message: t('officersView.hireMessage').replace('{name}', OFFICERS.value[officerType].name),
onConfirm: () => {
const success = hireOfficer(officerType, 7)
if (!success) {
alertDialog.value?.show({
title: t('officersView.hireFailed'),
message: t('officersView.insufficientResources')
})
}
confirmDialogTitle.value = t('officersView.hireTitle')
confirmDialogMessage.value = t('officersView.hireMessage').replace('{name}', OFFICERS.value[officerType].name)
confirmDialogAction.value = () => {
const success = hireOfficer(officerType, 7)
if (!success) {
alertDialogTitle.value = t('officersView.hireFailed')
alertDialogMessage.value = t('officersView.insufficientResources')
alertDialogOpen.value = true
}
})
}
confirmDialogOpen.value = true
}
const renewOfficer = (officerType: OfficerType, duration: number = 7): boolean => {
@@ -241,29 +284,26 @@
// 续约军官
const handleRenew = (officerType: OfficerType) => {
confirmDialog.value?.show({
title: t('officersView.renewTitle'),
message: t('officersView.renewMessage').replace('{name}', OFFICERS.value[officerType].name),
onConfirm: () => {
const success = renewOfficer(officerType, 7)
if (!success) {
alertDialog.value?.show({
title: t('officersView.renewFailed'),
message: t('officersView.insufficientResources')
})
}
confirmDialogTitle.value = t('officersView.renewTitle')
confirmDialogMessage.value = t('officersView.renewMessage').replace('{name}', OFFICERS.value[officerType].name)
confirmDialogAction.value = () => {
const success = renewOfficer(officerType, 7)
if (!success) {
alertDialogTitle.value = t('officersView.renewFailed')
alertDialogMessage.value = t('officersView.insufficientResources')
alertDialogOpen.value = true
}
})
}
confirmDialogOpen.value = true
}
// 解雇军官
const handleDismiss = (officerType: OfficerType): void => {
confirmDialog.value?.show({
title: t('officersView.dismissTitle'),
message: t('officersView.dismissMessage').replace('{name}', OFFICERS.value[officerType].name),
onConfirm: () => {
gameStore.player.officers[officerType] = officerLogic.createInactiveOfficer(officerType)
}
})
confirmDialogTitle.value = t('officersView.dismissTitle')
confirmDialogMessage.value = t('officersView.dismissMessage').replace('{name}', OFFICERS.value[officerType].name)
confirmDialogAction.value = () => {
gameStore.player.officers[officerType] = officerLogic.createInactiveOfficer(officerType)
}
confirmDialogOpen.value = true
}
</script>

View File

@@ -21,160 +21,153 @@
</div>
</div>
<!-- 资源显示 -->
<!-- 资源管理 -->
<Card>
<CardHeader>
<CardTitle>{{ t('overview.resourceOverview') }}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ t('common.resourceType') }}</TableHead>
<TableHead class="text-right">{{ t('resources.current') }}</TableHead>
<TableHead class="text-right">{{ t('resources.max') }}</TableHead>
<TableHead class="text-right">{{ t('resources.production') }}{{ t('resources.perHour') }}</TableHead>
<TableHead class="text-right">{{ t('resources.consumption') }}{{ t('resources.perHour') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="resourceType in resourceTypes" :key="resourceType.key">
<TableCell class="font-medium">
<div class="flex items-center gap-2">
<Tabs default-value="overview" class="w-full">
<TabsList class="grid w-full grid-cols-3">
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="production">产量详情</TabsTrigger>
<TabsTrigger value="consumption">消耗详情</TabsTrigger>
</TabsList>
<!-- 概览标签页 -->
<TabsContent value="overview" class="mt-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ t('common.resourceType') }}</TableHead>
<TableHead class="text-right">{{ t('resources.current') }}</TableHead>
<TableHead class="text-right">{{ t('resources.max') }}</TableHead>
<TableHead class="text-right">{{ t('resources.production') }}{{ t('resources.perHour') }}</TableHead>
<TableHead class="text-right">{{ t('resources.consumption') }}{{ t('resources.perHour') }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="resourceType in resourceTypes" :key="resourceType.key">
<TableCell class="font-medium">
<div class="flex items-center gap-2">
<ResourceIcon :type="resourceType.key" size="sm" />
{{ t(`resources.${resourceType.key}`) }}
</div>
</TableCell>
<TableCell
class="text-right"
:class="getResourceColor(planet.resources[resourceType.key], capacity?.[resourceType.key] || Infinity)"
>
{{ formatNumber(planet.resources[resourceType.key]) }}
</TableCell>
<TableCell class="text-right text-muted-foreground">
{{ formatNumber(capacity?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-green-600 dark:text-green-400">
+{{ formatNumber(production?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-red-600 dark:text-red-400">
<template v-if="resourceType.key === 'energy'">-{{ formatNumber(energyConsumption) }}</template>
<template v-else>-</template>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TabsContent>
<!-- 产量详情标签页 -->
<TabsContent value="production" class="mt-4">
<div class="space-y-4">
<div v-for="resourceType in resourceTypes" :key="resourceType.key" class="border-b last:border-b-0 pb-4 last:pb-0">
<div class="flex items-center gap-2 mb-2">
<ResourceIcon :type="resourceType.key" size="sm" />
{{ t(`resources.${resourceType.key}`) }}
<span class="font-semibold">{{ t(`resources.${resourceType.key}`) }}</span>
</div>
</TableCell>
<!-- 所有资源统一显示 -->
<TableCell
class="text-right"
:class="getResourceColor(planet.resources[resourceType.key], capacity?.[resourceType.key] || Infinity)"
<div v-if="productionBreakdown" class="ml-6 space-y-1 text-sm">
<!-- 电力有多个来源 -->
<template v-if="resourceType.key === 'energy' && productionBreakdown.energy.sources">
<div v-for="(source, idx) in productionBreakdown.energy.sources" :key="idx" class="flex justify-between">
<span class="text-muted-foreground">
{{ t(source.name) }}
<template v-if="source.name.startsWith('buildings.')">({{ t('common.level') }} {{ source.level }})</template>
<template v-else>({{ source.level }})</template>
</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(source.production)) }}/{{ t('resources.hour') }}
</span>
</div>
</template>
<!-- 其他资源单一建筑产量 -->
<template v-else>
<div class="flex justify-between">
<span class="text-muted-foreground">
{{ t(productionBreakdown[resourceType.key].buildingName) }}
({{ t('common.level') }} {{ productionBreakdown[resourceType.key].buildingLevel }})
</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(productionBreakdown[resourceType.key].baseProduction)) }}/{{ t('resources.hour') }}
</span>
</div>
</template>
<!-- 加成列表 -->
<div v-for="(bonus, idx) in productionBreakdown[resourceType.key].bonuses" :key="idx" class="flex justify-between">
<span class="text-muted-foreground ml-4">
{{ t(bonus.name) }} ({{ bonus.percentage > 0 ? '+' : '' }}{{ bonus.percentage }}%)
</span>
<span :class="bonus.value > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ bonus.value > 0 ? '+' : '' }}{{ formatNumber(Math.floor(bonus.value)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 最终产量 -->
<div class="flex justify-between font-semibold pt-1 border-t mt-1">
<span>{{ t('overview.totalProduction') }}</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(productionBreakdown[resourceType.key].finalProduction)) }}/{{ t('resources.hour') }}
</span>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- 消耗详情标签页 -->
<TabsContent value="consumption" class="mt-4">
<div class="space-y-2">
<!-- 各建筑消耗 -->
<div
v-for="consumptionType in consumptionTypes"
:key="consumptionType.key"
v-show="consumptionBreakdown && consumptionBreakdown[consumptionType.key].buildingLevel > 0"
class="flex justify-between text-sm"
>
{{ formatNumber(planet.resources[resourceType.key]) }}
</TableCell>
<TableCell class="text-right text-muted-foreground">
{{ formatNumber(capacity?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-green-600 dark:text-green-400">
+{{ formatNumber(production?.[resourceType.key] || 0) }}
</TableCell>
<TableCell class="text-right text-red-600 dark:text-red-400">
<template v-if="resourceType.key === 'energy'">
-{{ formatNumber(energyConsumption) }}
</template>
<template v-else>
-
</template>
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
<!-- 资源获取来源 -->
<Card>
<CardHeader>
<CardTitle>{{ t('overview.productionSources') }}</CardTitle>
<CardDescription>{{ t('overview.productionSourcesDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-4">
<div v-for="resourceType in resourceTypes" :key="resourceType.key" class="border-b last:border-b-0 pb-4 last:pb-0">
<div class="flex items-center gap-2 mb-2">
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="font-semibold">{{ t(`resources.${resourceType.key}`) }}</span>
</div>
<div v-if="productionBreakdown" class="ml-6 space-y-1 text-sm">
<!-- 建筑基础产量 -->
<div class="flex justify-between">
<span class="text-muted-foreground">
{{ t(productionBreakdown[resourceType.key].buildingName) }}
({{ t('common.level') }} {{ productionBreakdown[resourceType.key].buildingLevel }})
<span v-if="consumptionBreakdown" class="text-muted-foreground">
{{ t(consumptionBreakdown[consumptionType.key].buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown[consumptionType.key].buildingLevel }})
</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(productionBreakdown[resourceType.key].baseProduction)) }}/{{ t('resources.hour') }}
<span v-if="consumptionBreakdown" class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown[consumptionType.key].consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 加成列表 -->
<div v-for="(bonus, idx) in productionBreakdown[resourceType.key].bonuses" :key="idx" class="flex justify-between">
<span class="text-muted-foreground ml-4">
{{ t(bonus.name) }}
</span>
<span :class="bonus.value > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
{{ bonus.value > 0 ? '+' : '' }}{{ bonus.value }}%
<!-- 总消耗 -->
<div v-if="consumptionBreakdown" class="flex justify-between font-semibold pt-2 border-t">
<span>{{ t('overview.totalConsumption') }}</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.total)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 最终产量 -->
<div class="flex justify-between font-semibold pt-1 border-t mt-1">
<span>{{ t('overview.totalProduction') }}</span>
<span class="text-green-600 dark:text-green-400">
+{{ formatNumber(Math.floor(productionBreakdown[resourceType.key].finalProduction)) }}/{{ t('resources.hour') }}
</span>
<!-- 无消耗提示 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.total === 0" class="text-sm text-muted-foreground text-center py-2">
{{ t('overview.noConsumption') }}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 资源消耗来源 -->
<Card>
<CardHeader>
<CardTitle>{{ t('overview.consumptionSources') }}</CardTitle>
<CardDescription>{{ t('overview.consumptionSourcesDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-2">
<!-- 金属矿消耗 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.metalMine.buildingLevel > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">
{{ t(consumptionBreakdown.metalMine.buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown.metalMine.buildingLevel }})
</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.metalMine.consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 晶体矿消耗 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.crystalMine.buildingLevel > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">
{{ t(consumptionBreakdown.crystalMine.buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown.crystalMine.buildingLevel }})
</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.crystalMine.consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 重氢合成器消耗 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.deuteriumSynthesizer.buildingLevel > 0" class="flex justify-between text-sm">
<span class="text-muted-foreground">
{{ t(consumptionBreakdown.deuteriumSynthesizer.buildingName) }}
({{ t('common.level') }} {{ consumptionBreakdown.deuteriumSynthesizer.buildingLevel }})
</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.deuteriumSynthesizer.consumption)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 总消耗 -->
<div v-if="consumptionBreakdown" class="flex justify-between font-semibold pt-2 border-t">
<span>{{ t('overview.totalConsumption') }}</span>
<span class="text-red-600 dark:text-red-400">
-{{ formatNumber(Math.floor(consumptionBreakdown.total)) }}/{{ t('resources.hour') }}
</span>
</div>
<!-- 无消耗提示 -->
<div v-if="consumptionBreakdown && consumptionBreakdown.total === 0" class="text-sm text-muted-foreground text-center py-2">
{{ t('overview.noConsumption') }}
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
@@ -202,6 +195,7 @@
import { useGameConfig } from '@/composables/useGameConfig'
import { computed } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -210,7 +204,6 @@
import type { Planet } from '@/types/game'
import * as publicLogic from '@/logic/publicLogic'
import * as resourceLogic from '@/logic/resourceLogic'
import * as officerLogic from '@/logic/officerLogic'
const gameStore = useGameStore()
const { t } = useI18n()
@@ -228,8 +221,7 @@
// 资源产量详细breakdown
const productionBreakdown = computed(() => {
if (!planet.value) return null
const bonuses = officerLogic.calculateActiveBonuses(gameStore.player.officers, Date.now())
return resourceLogic.calculateProductionBreakdown(planet.value, bonuses)
return resourceLogic.calculateProductionBreakdown(planet.value, gameStore.player.officers, Date.now())
})
// 资源消耗详细breakdown
@@ -243,10 +235,13 @@
{ key: 'metal' as const },
{ key: 'crystal' as const },
{ key: 'deuterium' as const },
{ key: 'darkMatter' as const },
{ key: 'energy' as const }
{ key: 'energy' as const },
{ key: 'darkMatter' as const }
]
// 消耗类型配置
const consumptionTypes = [{ key: 'metalMine' as const }, { key: 'crystalMine' as const }, { key: 'deuteriumSynthesizer' as const }]
// 月球相关
const moon = computed(() => {
if (!planet.value || planet.value.isMoon) return null

View File

@@ -9,54 +9,44 @@
<Card v-for="techType in Object.values(TechnologyType)" :key="techType" class="relative">
<CardUnlockOverlay :requirements="TECHNOLOGIES[techType].requirements" :currentLevel="getTechLevel(techType)" />
<CardHeader>
<div class="flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="mb-2">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2">
<CardTitle
class="text-base sm:text-lg cursor-pointer hover:text-primary transition-colors"
class="text-sm sm:text-base lg:text-lg cursor-pointer hover:text-primary transition-colors underline decoration-dotted underline-offset-4 order-2 sm:order-1"
@click="detailDialog.openTechnology(techType, getTechLevel(techType))"
>
{{ TECHNOLOGIES[techType].name }}
</CardTitle>
<CardDescription class="text-xs sm:text-sm">{{ TECHNOLOGIES[techType].description }}</CardDescription>
<Badge variant="secondary" class="text-xs whitespace-nowrap self-start order-1 sm:order-2">
Lv {{ getTechLevel(techType) }}
</Badge>
</div>
<Badge variant="secondary" class="text-xs whitespace-nowrap flex-shrink-0">Lv {{ getTechLevel(techType) }}</Badge>
</div>
<CardDescription class="text-xs sm:text-sm">{{ TECHNOLOGIES[techType].description }}</CardDescription>
</CardHeader>
<CardContent>
<div class="space-y-2.5 sm:space-y-3">
<div class="text-xs sm:text-sm space-y-1.5 sm:space-y-2">
<p class="text-muted-foreground mb-1 sm:mb-2">{{ t('researchView.researchCost') }}:</p>
<div class="space-y-1 sm:space-y-1.5">
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.metal, getTechnologyCost(techType, getTechLevel(techType) + 1).metal)"
>
{{ formatNumber(getTechnologyCost(techType, getTechLevel(techType) + 1).metal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.crystal, getTechnologyCost(techType, getTechLevel(techType) + 1).crystal)"
>
{{ formatNumber(getTechnologyCost(techType, getTechLevel(techType) + 1).crystal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="resourceType.key !== 'darkMatter' || getTechnologyCost(techType, getTechLevel(techType) + 1).darkMatter > 0"
class="flex items-center gap-1.5 sm:gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="
getResourceCostColor(planet.resources.deuterium, getTechnologyCost(techType, getTechLevel(techType) + 1).deuterium)
getResourceCostColor(
planet.resources[resourceType.key],
getTechnologyCost(techType, getTechLevel(techType) + 1)[resourceType.key]
)
"
>
{{ formatNumber(getTechnologyCost(techType, getTechLevel(techType) + 1).deuterium) }}
{{ formatNumber(getTechnologyCost(techType, getTechLevel(techType) + 1)[resourceType.key]) }}
</span>
</div>
</div>
@@ -71,7 +61,19 @@
</div>
<!-- 提示对话框 -->
<AlertDialog ref="alertDialog" />
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -87,7 +89,15 @@
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ResourceIcon from '@/components/ResourceIcon.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import UnlockRequirement from '@/components/UnlockRequirement.vue'
import CardUnlockOverlay from '@/components/CardUnlockOverlay.vue'
import { formatNumber, getResourceCostColor } from '@/utils/format'
@@ -101,7 +111,19 @@
const { TECHNOLOGIES, BUILDINGS } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const player = computed(() => gameStore.player)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
// 资源类型配置(用于成本显示)
const costResourceTypes = [
{ key: 'metal' as const },
{ key: 'crystal' as const },
{ key: 'deuterium' as const },
{ key: 'darkMatter' as const }
]
const researchTechnology = (techType: TechnologyType): boolean => {
if (!gameStore.currentPlanet) return false
@@ -117,7 +139,8 @@
gameStore.currentPlanet,
techType,
currentLevel,
gameStore.player.officers
gameStore.player.officers,
gameStore.player.technologies
)
gameStore.player.researchQueue.push(queueItem)
return true
@@ -149,7 +172,11 @@
return t('researchView.maxLevelReached') // "等级已满"
}
if (player.value.researchQueue.length > 0) return t('researchView.research')
// 检查研究队列是否已满
const maxQueue = publicLogic.getMaxResearchQueue(gameStore.player.technologies)
if (player.value.researchQueue.length >= maxQueue) {
return t('researchView.research')
}
// 检查前置条件
if (!checkUpgradeRequirements(techType)) {
@@ -196,19 +223,17 @@
const handleResearch = (techType: TechnologyType) => {
// 检查前置条件
if (!checkUpgradeRequirements(techType)) {
alertDialog.value?.show({
title: t('common.requirementsNotMet'),
message: getRequirementsList(techType)
})
alertDialogTitle.value = t('common.requirementsNotMet')
alertDialogMessage.value = getRequirementsList(techType)
alertDialogOpen.value = true
return
}
const success = researchTechnology(techType)
if (!success) {
alertDialog.value?.show({
title: t('researchView.researchFailed'),
message: t('researchView.researchFailedMessage')
})
alertDialogTitle.value = t('researchView.researchFailed')
alertDialogMessage.value = t('researchView.researchFailedMessage')
alertDialogOpen.value = true
}
}
@@ -229,7 +254,11 @@
return false
}
if (player.value.researchQueue.length > 0) return false
// 检查研究队列是否已满
const maxQueue = publicLogic.getMaxResearchQueue(gameStore.player.technologies)
if (player.value.researchQueue.length >= maxQueue) {
return false
}
const cost = getTechnologyCost(techType, currentLevel + 1)
@@ -237,7 +266,8 @@
publicLogic.checkRequirements(planet.value, gameStore.player.technologies, config.requirements) &&
planet.value.resources.metal >= cost.metal &&
planet.value.resources.crystal >= cost.crystal &&
planet.value.resources.deuterium >= cost.deuterium
planet.value.resources.deuterium >= cost.deuterium &&
planet.value.resources.darkMatter >= cost.darkMatter
)
}

View File

@@ -164,7 +164,7 @@
let confirmCallback: (() => void) | null = null
const openGithub = () => {
window.open(`https://github.com/${pkg.author}/${pkg.name}`, '_blank')
window.open(`https://github.com/${pkg.author.name}/${pkg.name}`, '_blank')
}
const openQQGroup = () => {
@@ -180,6 +180,8 @@
const gameData = localStorage.getItem(pkg.name)
// 获取地图数据
const universeData = localStorage.getItem(`${pkg.name}-universe`)
// 获取npc数据
const npcData = localStorage.getItem(`${pkg.name}-npcs`)
if (!gameData) {
toast.error(t('settings.exportFailed'))
@@ -189,6 +191,7 @@
// 合并数据
const exportData = {
game: gameData,
npcs: npcData,
universe: universeData || null
}
@@ -247,6 +250,10 @@
localStorage.setItem(`${pkg.name}-universe`, importData.universe)
}
if (importData.npcs) {
localStorage.setItem(`${pkg.name}-npcs`, importData.npcs)
}
toast.success(t('settings.importSuccess'))
// 延迟刷新页面以让toast显示
setTimeout(() => window.location.reload(), 1000)

View File

@@ -23,7 +23,7 @@
class="h-full transition-all duration-300"
:class="fleetStorageUsage > maxFleetStorage ? 'bg-destructive' : 'bg-primary'"
:style="{ width: `${Math.min((fleetStorageUsage / maxFleetStorage) * 100, 100)}%` }"
></div>
/>
</div>
</div>
</div>
@@ -31,9 +31,9 @@
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 sm:gap-4">
<Card v-for="shipType in Object.values(ShipType)" :key="shipType" class="relative">
<CardUnlockOverlay :requirements="SHIPS[shipType].requirements" />
<CardHeader>
<CardHeader class="pb-3">
<CardTitle
class="text-base sm:text-lg cursor-pointer hover:text-primary transition-colors"
class="text-sm sm:text-base lg:text-lg cursor-pointer hover:text-primary transition-colors underline decoration-dotted underline-offset-4 mb-2"
@click="detailDialog.openShip(shipType)"
>
{{ SHIPS[shipType].name }}
@@ -64,34 +64,19 @@
<div class="text-xs sm:text-sm space-y-1.5 sm:space-y-2">
<p class="text-muted-foreground mb-1 sm:mb-2">{{ t('shipyardView.unitCost') }}:</p>
<div class="space-y-1 sm:space-y-1.5">
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="resourceType.key !== 'darkMatter' || SHIPS[shipType].cost.darkMatter > 0"
class="flex items-center gap-1.5 sm:gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.metal, SHIPS[shipType].cost.metal)"
:class="getResourceCostColor(planet.resources[resourceType.key], SHIPS[shipType].cost[resourceType.key])"
>
{{ formatNumber(SHIPS[shipType].cost.metal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.crystal, SHIPS[shipType].cost.crystal)"
>
{{ formatNumber(SHIPS[shipType].cost.crystal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.deuterium, SHIPS[shipType].cost.deuterium)"
>
{{ formatNumber(SHIPS[shipType].cost.deuterium) }}
{{ formatNumber(SHIPS[shipType].cost[resourceType.key]) }}
</span>
</div>
</div>
@@ -112,34 +97,19 @@
<div v-if="quantities[shipType] > 0" class="text-xs sm:text-sm space-y-1.5 sm:space-y-2 p-2.5 sm:p-3 bg-muted rounded-lg">
<p class="font-medium text-muted-foreground">{{ t('shipyardView.totalCost') }}:</p>
<div class="space-y-1 sm:space-y-1.5">
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="metal" size="sm" />
<span class="text-xs">{{ t('resources.metal') }}:</span>
<div
v-for="resourceType in costResourceTypes"
:key="resourceType.key"
v-show="resourceType.key !== 'darkMatter' || getTotalCost(shipType).darkMatter > 0"
class="flex items-center gap-1.5 sm:gap-2"
>
<ResourceIcon :type="resourceType.key" size="sm" />
<span class="text-xs">{{ t(`resources.${resourceType.key}`) }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.metal, getTotalCost(shipType).metal)"
:class="getResourceCostColor(planet.resources[resourceType.key], getTotalCost(shipType)[resourceType.key])"
>
{{ formatNumber(getTotalCost(shipType).metal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="crystal" size="sm" />
<span class="text-xs">{{ t('resources.crystal') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.crystal, getTotalCost(shipType).crystal)"
>
{{ formatNumber(getTotalCost(shipType).crystal) }}
</span>
</div>
<div class="flex items-center gap-1.5 sm:gap-2">
<ResourceIcon type="deuterium" size="sm" />
<span class="text-xs">{{ t('resources.deuterium') }}:</span>
<span
class="font-medium text-xs sm:text-sm"
:class="getResourceCostColor(planet.resources.deuterium, getTotalCost(shipType).deuterium)"
>
{{ formatNumber(getTotalCost(shipType).deuterium) }}
{{ formatNumber(getTotalCost(shipType)[resourceType.key]) }}
</span>
</div>
</div>
@@ -152,7 +122,19 @@
</div>
<!-- 提示对话框 -->
<AlertDialog ref="alertDialog" />
<AlertDialog :open="alertDialogOpen" @update:open="alertDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{{ alertDialogTitle }}</AlertDialogTitle>
<AlertDialogDescription class="whitespace-pre-line">
{{ alertDialogMessage }}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction>{{ t('common.confirm') }}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -168,7 +150,15 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import ResourceIcon from '@/components/ResourceIcon.vue'
import AlertDialog from '@/components/AlertDialog.vue'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import UnlockRequirement from '@/components/UnlockRequirement.vue'
import CardUnlockOverlay from '@/components/CardUnlockOverlay.vue'
import { formatNumber, getResourceCostColor } from '@/utils/format'
@@ -181,7 +171,19 @@
const { t } = useI18n()
const { SHIPS } = useGameConfig()
const planet = computed(() => gameStore.currentPlanet)
const alertDialog = ref<InstanceType<typeof AlertDialog> | null>(null)
// AlertDialog 状态
const alertDialogOpen = ref(false)
const alertDialogTitle = ref('')
const alertDialogMessage = ref('')
// 资源类型配置(用于成本显示)
const costResourceTypes = [
{ key: 'metal' as const },
{ key: 'crystal' as const },
{ key: 'deuterium' as const },
{ key: 'darkMatter' as const }
]
// 舰队仓储使用量
const fleetStorageUsage = computed(() => {
@@ -201,11 +203,15 @@
[ShipType.HeavyFighter]: 0,
[ShipType.Cruiser]: 0,
[ShipType.Battleship]: 0,
[ShipType.Battlecruiser]: 0,
[ShipType.Bomber]: 0,
[ShipType.Destroyer]: 0,
[ShipType.SmallCargo]: 0,
[ShipType.LargeCargo]: 0,
[ShipType.ColonyShip]: 0,
[ShipType.Recycler]: 0,
[ShipType.EspionageProbe]: 0,
[ShipType.SolarSatellite]: 0,
[ShipType.DarkMatterHarvester]: 0,
[ShipType.Deathstar]: 0
})
@@ -223,19 +229,17 @@
const handleBuild = (shipType: ShipType) => {
const quantity = quantities.value[shipType]
if (quantity <= 0) {
alertDialog.value?.show({
title: t('shipyardView.inputError'),
message: t('shipyardView.inputErrorMessage')
})
alertDialogTitle.value = t('shipyardView.inputError')
alertDialogMessage.value = t('shipyardView.inputErrorMessage')
alertDialogOpen.value = true
return
}
const success = buildShip(shipType, quantity)
if (!success) {
alertDialog.value?.show({
title: t('shipyardView.buildFailed'),
message: t('shipyardView.buildFailedMessage')
})
alertDialogTitle.value = t('shipyardView.buildFailed')
alertDialogMessage.value = t('shipyardView.buildFailedMessage')
alertDialogOpen.value = true
} else {
quantities.value[shipType] = 0
}
@@ -252,14 +256,16 @@
const totalCost = {
metal: config.cost.metal * quantity,
crystal: config.cost.crystal * quantity,
deuterium: config.cost.deuterium * quantity
deuterium: config.cost.deuterium * quantity,
darkMatter: config.cost.darkMatter * quantity
}
return (
publicLogic.checkRequirements(planet.value, gameStore.player.technologies, config.requirements) &&
planet.value.resources.metal >= totalCost.metal &&
planet.value.resources.crystal >= totalCost.crystal &&
planet.value.resources.deuterium >= totalCost.deuterium
planet.value.resources.deuterium >= totalCost.deuterium &&
planet.value.resources.darkMatter >= totalCost.darkMatter
)
}
@@ -270,7 +276,8 @@
return {
metal: config.cost.metal * quantity,
crystal: config.cost.crystal * quantity,
deuterium: config.cost.deuterium * quantity
deuterium: config.cost.deuterium * quantity,
darkMatter: config.cost.darkMatter * quantity
}
}
</script>