Files
ogame-vue-ts/src/views/DiplomacyView.vue
谦君 724a70bebb docs: 新增西班牙语和日语README并优化多语言文档
新增README-ES.md(西班牙语)和README-JA.md(日语)文档,完善多语言README互链。优化各语言README徽章、技术栈、外链格式及语言切换区,提升文档一致性与可读性。
2025-12-25 18:25:08 +08:00

1069 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="container mx-auto p-4 sm:p-6 space-y-6">
<div class="flex items-start 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 class="flex items-center gap-2 shrink-0">
<!-- 视图模式切换 -->
<div class="flex items-center border rounded-md">
<Button
variant="ghost"
size="sm"
class="h-8 px-2 rounded-r-none"
:class="{ 'bg-accent': viewMode === 'list' }"
@click="viewMode = 'list'"
:title="t('diplomacy.viewMode.list')"
>
<List class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
class="h-8 px-2 rounded-l-none border-l"
:class="{ 'bg-accent': viewMode === 'card' }"
@click="viewMode = 'card'"
:title="t('diplomacy.viewMode.card')"
>
<LayoutGrid class="h-4 w-4" />
</Button>
</div>
<!-- NPC诊断按钮 -->
<Button @click="showNPCDiagnostic" variant="outline" size="sm">
<Activity class="h-4 w-4 sm:mr-2" />
<span class="hidden sm:inline">{{ t('diplomacy.diagnostic.button') }}</span>
</Button>
</div>
</div>
<!-- NPC诊断对话框 -->
<Dialog v-model:open="npcDiagnosticOpen">
<ScrollableDialogContent container-class="max-w-4xl">
<template #header>
<DialogTitle>{{ t('diplomacy.diagnostic.title') }}</DialogTitle>
<DialogDescription>
<div class="text-sm mt-2">
{{
t('diplomacy.diagnostic.description', {
points: gameStore.player.points || 0,
spyInterval: Math.floor(behaviorConfig.spyInterval / 60),
attackInterval: Math.floor(behaviorConfig.attackInterval / 60),
attackProb: (behaviorConfig.attackProbability * 100).toFixed(0)
})
}}
</div>
</DialogDescription>
</template>
<!-- 诊断对话框搜索框 -->
<div class="relative mb-4">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input v-model="diagnosticSearchQuery" type="text" :placeholder="t('diplomacy.searchPlaceholder')" class="pl-10" />
</div>
<div v-if="filteredDiagnostics.length === 0" class="text-center py-8 text-muted-foreground">
{{ t('diplomacy.diagnostic.noData') }}
</div>
<div v-else class="space-y-4">
<div v-for="diagnostic in paginatedDiagnostics" :key="diagnostic.npcId" class="border rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-bold text-lg">{{ diagnostic.npcName }}</h3>
<Badge :variant="getRelationBadgeVariant(diagnostic.relationStatusKey)">
{{ getLocalizedRelationStatus(diagnostic.relationStatusKey) }}
</Badge>
</div>
<div class="grid grid-cols-2 gap-2 text-sm mb-3">
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.difficulty') }}:</span>
<span class="font-medium">{{ t(`diplomacy.diagnostic.difficultyLevels.${diagnostic.difficulty}`) }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.aiType') }}:</span>
<span
class="font-medium"
:title="diagnostic.aiType ? t(`diplomacy.diagnostic.aiTypeDescriptions.${diagnostic.aiType}`) : ''"
>
{{ diagnostic.aiType ? t(`diplomacy.diagnostic.aiTypes.${diagnostic.aiType}`) : '-' }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.reputation') }}:</span>
<span class="font-medium">{{ diagnostic.reputation }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.spyProbes') }}:</span>
<span class="font-medium">{{ diagnostic.spyProbes }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.fleetPower') }}:</span>
<span class="font-medium">{{ diagnostic.totalFleetPower }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.canSpy') }}:</span>
<span :class="diagnostic.canSpy ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
{{ diagnostic.canSpy ? t('diplomacy.diagnostic.yes') : t('diplomacy.diagnostic.no') }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.canAttack') }}:</span>
<span :class="diagnostic.canAttack ? 'text-green-600 font-semibold' : 'text-red-600 font-semibold'">
{{ diagnostic.canAttack ? t('diplomacy.diagnostic.yes') : t('diplomacy.diagnostic.no') }}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.attackProbability') }}:</span>
<span class="font-medium">{{ (diagnostic.attackProbability * 100).toFixed(0) }}%</span>
</div>
<div class="flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.nextSpy') }}:</span>
<span class="font-medium">
<template v-if="diagnostic.nextSpyIn > 0">
{{
t('diplomacy.diagnostic.timeFormat', { min: Math.floor(diagnostic.nextSpyIn / 60), sec: diagnostic.nextSpyIn % 60 })
}}
</template>
<template v-else>
<span class="text-green-600">{{ t('diplomacy.diagnostic.anytime') }}</span>
</template>
</span>
</div>
<div class="col-span-2 flex items-center gap-2">
<span class="text-muted-foreground">{{ t('diplomacy.diagnostic.nextAttack') }}:</span>
<span class="font-medium">
<template v-if="diagnostic.nextAttackIn > 0">
{{
t('diplomacy.diagnostic.timeFormat', {
min: Math.floor(diagnostic.nextAttackIn / 60),
sec: diagnostic.nextAttackIn % 60
})
}}
</template>
<template v-else>
<span class="text-green-600">{{ t('diplomacy.diagnostic.anytime') }}</span>
</template>
</span>
</div>
</div>
<div v-if="diagnostic.reasons.length > 0" class="mt-3 p-3 bg-muted rounded text-xs">
<div class="font-semibold mb-2">{{ t('diplomacy.diagnostic.statusExplanation') }}:</div>
<ul class="list-disc list-inside space-y-1">
<li v-for="(reason, idx) in diagnostic.reasons" :key="idx">{{ translateReason(reason) }}</li>
</ul>
</div>
</div>
<!-- 分页控制 -->
<Pagination
v-if="diagnosticTotalPages > 1"
v-model:page="diagnosticPage"
:total="filteredDiagnostics.length"
:items-per-page="DIAGNOSTIC_ITEMS_PER_PAGE"
:sibling-count="1"
show-edges
class="mt-6"
>
<PaginationContent>
<PaginationPrevious>{{ t('pagination.previous') }}</PaginationPrevious>
<template v-for="(pageNum, index) in diagnosticPageNumbers" :key="index">
<PaginationItem v-if="typeof pageNum === 'number'" :value="pageNum" :is-active="pageNum === diagnosticPage">
{{ pageNum }}
</PaginationItem>
<span v-else class="px-2 text-muted-foreground">{{ pageNum }}</span>
</template>
<PaginationNext>{{ t('pagination.next') }}</PaginationNext>
</PaginationContent>
</Pagination>
</div>
</ScrollableDialogContent>
</Dialog>
<!-- NPC互动面板 - 贸易提议情报联合攻击邀请 -->
<div v-if="hasNpcInteractions" class="space-y-4">
<Collapsible v-model:open="interactionPanelOpen" class="border rounded-lg">
<CollapsibleTrigger class="flex items-center justify-between w-full p-4 hover:bg-accent/50 transition-colors">
<div class="flex items-center gap-2">
<Handshake class="h-5 w-5 text-primary" />
<span class="font-semibold">{{ t('npcBehavior.trade.title') }} & {{ t('npcBehavior.intel.title') }}</span>
<Badge variant="destructive" v-if="totalInteractionCount > 0">{{ totalInteractionCount }}</Badge>
</div>
<ChevronDown class="h-4 w-4 transition-transform" :class="{ 'rotate-180': interactionPanelOpen }" />
</CollapsibleTrigger>
<CollapsibleContent class="px-4 pb-4 space-y-4">
<!-- 贸易提议 -->
<div v-if="activeTradeOffers.length > 0">
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
<ArrowLeftRight class="h-4 w-4" />
{{ t('npcBehavior.trade.title') }} ({{ activeTradeOffers.length }})
</h3>
<div class="grid gap-2">
<Card v-for="offer in activeTradeOffers" :key="offer.id" class="p-3">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-1">
<div class="font-medium">{{ getNpcName(offer.npcId) }}</div>
<div class="text-sm text-muted-foreground">
<span class="text-green-600 dark:text-green-400">{{ t('npcBehavior.trade.offers') }}:</span>
{{ formatResources(offer.offeredResources) }}
</div>
<div class="text-sm text-muted-foreground">
<span class="text-red-600 dark:text-red-400">{{ t('npcBehavior.trade.requests') }}:</span>
{{ formatResources(offer.requestedResources) }}
</div>
<div class="text-xs text-muted-foreground">
{{ t('npcBehavior.trade.expiresIn') }}: {{ formatTimeRemaining(offer.expiresAt) }}
</div>
</div>
<div class="flex gap-2">
<Button size="sm" variant="default" @click="acceptTradeOffer(offer)" :disabled="!canAcceptTrade(offer)">
{{ t('npcBehavior.trade.accept') }}
</Button>
<Button size="sm" variant="outline" @click="declineTradeOffer(offer)">
{{ t('npcBehavior.trade.decline') }}
</Button>
</div>
</div>
</Card>
</div>
</div>
<!-- 情报报告 -->
<div v-if="unreadIntelReports.length > 0">
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
<Eye class="h-4 w-4" />
{{ t('npcBehavior.intel.title') }} ({{ unreadIntelReports.length }})
</h3>
<div class="grid gap-2">
<Card v-for="intel in unreadIntelReports" :key="intel.id" class="p-3">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-1">
<div class="font-medium">{{ t('npcBehavior.intel.from') }}: {{ getNpcName(intel.fromNpcId) }}</div>
<div class="text-sm text-muted-foreground">
{{ t('npcBehavior.intel.target') }}: {{ getNpcName(intel.targetNpcId) }}
</div>
<div class="text-sm">
<Badge variant="outline">{{ t(`npcBehavior.intel.types.${intel.intelType}`) }}</Badge>
</div>
<div v-if="intel.data?.fleet" class="text-sm text-muted-foreground">
{{ t('npcBehavior.intel.fleetInfo') }}: {{ formatFleetInfo(intel.data.fleet) }}
</div>
<div v-if="intel.data?.resources" class="text-sm text-muted-foreground">
{{ t('npcBehavior.intel.resourceInfo') }}: {{ formatResources(intel.data.resources as Resources) }}
</div>
</div>
<Button size="sm" variant="ghost" @click="markIntelAsRead(intel)">
{{ t('npcBehavior.intel.markAsRead') }}
</Button>
</div>
</Card>
</div>
</div>
<!-- 联合攻击邀请 -->
<div v-if="activeJointAttackInvites.length > 0">
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
<Swords class="h-4 w-4" />
{{ t('npcBehavior.jointAttack.title') }} ({{ activeJointAttackInvites.length }})
</h3>
<div class="grid gap-2">
<Card v-for="invite in activeJointAttackInvites" :key="invite.id" class="p-3">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-1">
<div class="font-medium">{{ t('npcBehavior.jointAttack.from') }}: {{ getNpcName(invite.fromNpcId) }}</div>
<div class="text-sm text-muted-foreground">
{{ t('npcBehavior.jointAttack.target') }}: {{ getNpcName(invite.targetNpcId) }}
</div>
<div class="text-sm text-muted-foreground">
{{ t('npcBehavior.jointAttack.targetPlanet') }}: [{{ invite.targetPosition.galaxy }}:{{
invite.targetPosition.system
}}:{{ invite.targetPosition.position }}]
</div>
<div class="text-sm text-muted-foreground">
{{ t('npcBehavior.jointAttack.lootShare') }}: {{ (invite.expectedLootRatio * 100).toFixed(0) }}%
</div>
<div class="text-xs text-muted-foreground">
{{ t('npcBehavior.jointAttack.expiresIn') }}: {{ formatTimeRemaining(invite.expiresAt) }}
</div>
</div>
<div class="flex gap-2">
<Button size="sm" variant="default" @click="acceptJointAttack(invite)">
{{ t('npcBehavior.jointAttack.accept') }}
</Button>
<Button size="sm" variant="outline" @click="declineJointAttack(invite)">
{{ t('npcBehavior.jointAttack.decline') }}
</Button>
</div>
</div>
</Card>
</div>
</div>
<!-- 无互动内容提示 -->
<div v-if="!hasActiveInteractions" class="text-center py-4 text-muted-foreground">
{{ t('npcBehavior.trade.noOffers') }}
</div>
</CollapsibleContent>
</Collapsible>
</div>
<!-- 搜索框 -->
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input v-model="searchQuery" type="text" :placeholder="t('diplomacy.searchPlaceholder')" class="pl-10" />
</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="outline"
class="ml-2 bg-blue-100 dark:bg-blue-950 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700"
>
{{ allNpcs.length }}
</Badge>
</TabsTrigger>
<TabsTrigger value="friendly">
{{ t('diplomacy.tabs.friendly') }}
<Badge
variant="outline"
class="ml-2 bg-green-100 dark:bg-green-950 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700"
>
{{ friendlyNpcs.length }}
</Badge>
</TabsTrigger>
<TabsTrigger value="neutral">
{{ t('diplomacy.tabs.neutral') }}
<Badge
variant="outline"
class="ml-2 bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600"
>
{{ neutralNpcs.length }}
</Badge>
</TabsTrigger>
<TabsTrigger value="hostile">
{{ t('diplomacy.tabs.hostile') }}
<Badge
variant="outline"
class="ml-2 bg-red-100 dark:bg-red-950 text-red-700 dark:text-red-300 border-red-300 dark:border-red-700"
>
{{ hostileNpcs.length }}
</Badge>
</TabsTrigger>
</TabsList>
<!-- 全部NPC -->
<TabsContent value="all" class="space-y-4 mt-6">
<Empty v-if="allNpcs.length === 0" class="border rounded-lg">
<EmptyContent>
<Users class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('diplomacy.noNpcs') }}</EmptyDescription>
</EmptyContent>
</Empty>
<template v-else>
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-20">
<NpcRelationCard
v-for="npc in paginatedAllNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
<!-- 列表视图 -->
<div v-else class="space-y-2 pb-20">
<NpcRelationRow
v-for="npc in paginatedAllNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
</template>
</TabsContent>
<!-- 友好NPC -->
<TabsContent value="friendly" class="space-y-4 mt-6">
<Empty v-if="friendlyNpcs.length === 0" class="border rounded-lg">
<EmptyContent>
<Heart class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('diplomacy.noFriendlyNpcs') }}</EmptyDescription>
</EmptyContent>
</Empty>
<template v-else>
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-20">
<NpcRelationCard
v-for="npc in paginatedFriendlyNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
<!-- 列表视图 -->
<div v-else class="space-y-2 pb-20">
<NpcRelationRow
v-for="npc in paginatedFriendlyNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
</template>
</TabsContent>
<!-- 中立NPC -->
<TabsContent value="neutral" class="space-y-4 mt-6">
<Empty v-if="neutralNpcs.length === 0" class="border rounded-lg">
<EmptyContent>
<Minus class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('diplomacy.noNeutralNpcs') }}</EmptyDescription>
</EmptyContent>
</Empty>
<template v-else>
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-20">
<NpcRelationCard
v-for="npc in paginatedNeutralNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
<!-- 列表视图 -->
<div v-else class="space-y-2 pb-20">
<NpcRelationRow
v-for="npc in paginatedNeutralNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
</template>
</TabsContent>
<!-- 敌对NPC -->
<TabsContent value="hostile" class="space-y-4 mt-6">
<Empty v-if="hostileNpcs.length === 0" class="border rounded-lg">
<EmptyContent>
<Swords class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('diplomacy.noHostileNpcs') }}</EmptyDescription>
</EmptyContent>
</Empty>
<template v-else>
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="grid grid-cols-1 md:grid-cols-2 gap-4 pb-20">
<NpcRelationCard
v-for="npc in paginatedHostileNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
<!-- 列表视图 -->
<div v-else class="space-y-2 pb-20">
<NpcRelationRow
v-for="npc in paginatedHostileNpcs"
:key="npc.id"
:ref="setCardRef(npc.id)"
:npc="npc"
:relation="getRelation(npc.id)"
:class="{ 'npc-highlight': highlightedNpcId === npc.id }"
/>
</div>
</template>
</TabsContent>
</Tabs>
<!-- 固定底部分页 -->
<FixedPagination v-model:page="currentPageValue" :total-pages="currentTotalPages" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { useNPCStore } from '@/stores/npcStore'
import { useI18n } from '@/composables/useI18n'
import { toast } from 'vue-sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Dialog, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import ScrollableDialogContent from '@/components/ui/dialog/ScrollableDialogContent.vue'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import {
FixedPagination,
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { Input } from '@/components/ui/input'
import NpcRelationCard from '@/components/npc/NpcRelationCard.vue'
import NpcRelationRow from '@/components/npc/NpcRelationRow.vue'
import { RelationStatus } from '@/types/game'
import type { DiplomaticRelation, TradeOffer, IntelReport, JointAttackInvite, Resources } from '@/types/game'
import * as npcBehaviorLogic from '@/logic/npcBehaviorLogic'
import {
Search,
Users,
Heart,
Minus,
Swords,
Activity,
LayoutGrid,
List,
Handshake,
ChevronDown,
ArrowLeftRight,
Eye
} from 'lucide-vue-next'
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
const route = useRoute()
const gameStore = useGameStore()
const npcStore = useNPCStore()
const { t } = useI18n()
const activeTab = ref('all')
// NPC互动面板状态
const interactionPanelOpen = ref(true)
// 视图模式: 'card' | 'list'
const viewMode = ref<'card' | 'list'>('list')
// NPC卡片引用 Map存储组件实例
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cardRefs = ref<Map<string, any>>(new Map())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const setCardRef = (npcId: string) => (el: any) => {
if (el) {
cardRefs.value.set(npcId, el)
} else {
cardRefs.value.delete(npcId)
}
}
// 高亮状态
const highlightedNpcId = ref<string | null>(null)
// 搜索功能
const searchQuery = ref('')
// NPC诊断功能
const npcDiagnosticOpen = ref(false)
const npcDiagnostics = ref<npcBehaviorLogic.NPCDiagnosticInfo[]>([])
const diagnosticPage = ref(1)
const diagnosticSearchQuery = ref('')
const DIAGNOSTIC_ITEMS_PER_PAGE = 10
const behaviorConfig = computed(() => {
return npcBehaviorLogic.calculateDynamicBehavior(gameStore.player.points || 0)
})
const showNPCDiagnostic = () => {
const currentTime = Date.now()
npcDiagnostics.value = npcBehaviorLogic.diagnoseNPCBehavior(npcStore.npcs, gameStore.player, currentTime)
diagnosticPage.value = 1 // 重置分页
diagnosticSearchQuery.value = '' // 重置搜索
npcDiagnosticOpen.value = true
}
// 诊断搜索过滤
const filteredDiagnostics = computed(() => {
if (!diagnosticSearchQuery.value.trim()) return npcDiagnostics.value
const query = diagnosticSearchQuery.value.toLowerCase().trim()
return npcDiagnostics.value.filter(d => d.npcName.toLowerCase().includes(query) || d.npcId.toLowerCase().includes(query))
})
// 诊断弹窗分页
const diagnosticTotalPages = computed(() => Math.ceil(filteredDiagnostics.value.length / DIAGNOSTIC_ITEMS_PER_PAGE))
const paginatedDiagnostics = computed(() => {
const start = (diagnosticPage.value - 1) * DIAGNOSTIC_ITEMS_PER_PAGE
const end = start + DIAGNOSTIC_ITEMS_PER_PAGE
return filteredDiagnostics.value.slice(start, end)
})
const diagnosticPageNumbers = computed(() => getPageNumbers(diagnosticPage.value, diagnosticTotalPages.value))
// 诊断面板关系状态本地化
const getLocalizedRelationStatus = (statusKey: string) => {
switch (statusKey) {
case 'friendly':
return t('diplomacy.status.friendly')
case 'hostile':
return t('diplomacy.status.hostile')
case 'neutral':
return t('diplomacy.status.neutral')
case 'noRelation':
return t('diplomacy.diagnostic.noRelation')
case 'noRelationNeutral':
return t('diplomacy.diagnostic.noRelationNeutral')
default:
return t('diplomacy.status.neutral')
}
}
// 诊断面板关系Badge样式
const getRelationBadgeVariant = (statusKey: string) => {
switch (statusKey) {
case 'friendly':
return 'default'
case 'hostile':
return 'destructive'
default:
return 'secondary'
}
}
// 翻译诊断原因
const translateReason = (reason: { key: string; params?: Record<string, string | number> }) => {
const translationKey = `diplomacy.diagnostic.reasons.${reason.key}`
return reason.params ? t(translationKey, reason.params) : t(translationKey)
}
// 检测并生成NPC盟友
const initializeNPCAllies = () => {
const npcs = npcStore.npcs
if (npcs.length < 2) return // 至少需要2个NPC才能生成盟友关系
npcs.forEach(npc => {
// 如果NPC没有盟友列表,初始化为空数组
if (!npc.allies) {
npc.allies = []
}
// 如果NPC没有盟友,随机生成1-2个盟友
if (npc.allies.length === 0) {
const otherNpcs = npcs.filter(n => n.id !== npc.id)
if (otherNpcs.length === 0) return
// 随机选择1-2个盟友
const allyCount = Math.min(Math.floor(Math.random() * 2) + 1, otherNpcs.length)
const shuffled = [...otherNpcs].sort(() => Math.random() - 0.5)
const selectedAllies = shuffled.slice(0, allyCount)
selectedAllies.forEach(ally => {
// 添加双向盟友关系
if (!npc.allies!.includes(ally.id)) {
npc.allies!.push(ally.id)
}
// 确保盟友也有盟友列表
if (!ally.allies) {
ally.allies = []
}
// 确保双向关系
if (!ally.allies.includes(npc.id)) {
ally.allies.push(npc.id)
}
})
}
})
}
// 滚动到指定NPC卡片
const scrollToNpcCard = (npcId: string) => {
// 切换到"全部"标签
activeTab.value = 'all'
// 等待DOM更新后再滚动
nextTick(() => {
// 找到目标NPC在列表中的索引
const npcIndex = allNpcs.value.findIndex(npc => npc.id === npcId)
if (npcIndex === -1) return
// 计算目标NPC所在的页面
const targetPage = Math.floor(npcIndex / ITEMS_PER_PAGE) + 1
currentPage.value.all = targetPage
// 再次等待分页更新后滚动到卡片
nextTick(() => {
// 从 cardRefs 获取组件实例
const cardComponent = cardRefs.value.get(npcId)
const targetEl = cardComponent?.$el as HTMLElement | undefined
if (targetEl) {
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 添加高亮效果
highlightedNpcId.value = npcId
setTimeout(() => {
highlightedNpcId.value = null
}, 3000)
}
})
})
}
// 组件挂载时初始化NPC盟友
onMounted(() => {
initializeNPCAllies()
// 检查URL query参数如果有npcId则滚动到该NPC
const npcIdFromQuery = route.query.npcId as string | undefined
if (npcIdFromQuery) {
scrollToNpcCard(npcIdFromQuery)
}
// 监听滚动到NPC卡片的事件
const handleScrollToNpc = (event: Event) => {
const customEvent = event as CustomEvent<{ npcId: string }>
scrollToNpcCard(customEvent.detail.npcId)
}
document.addEventListener('scrollToNpc', handleScrollToNpc)
// 清理事件监听器
onUnmounted(() => {
document.removeEventListener('scrollToNpc', handleScrollToNpc)
})
})
// 分页状态
const ITEMS_PER_PAGE = 20
const currentPage = ref<Record<string, number>>({
all: 1,
friendly: 1,
neutral: 1,
hostile: 1
})
// 获取NPC对玩家的关系统一使用 npc.relations
const getRelation = (npcId: string): DiplomaticRelation | undefined => {
const npc = npcStore.npcs.find(n => n.id === npcId)
return npc?.relations?.[gameStore.player.id]
}
// 搜索过滤函数
const matchesSearch = (npc: (typeof npcStore.npcs)[0]) => {
if (!searchQuery.value.trim()) return true
const query = searchQuery.value.toLowerCase().trim()
return npc.name.toLowerCase().includes(query) || npc.id.toLowerCase().includes(query)
}
// 按关系状态分类NPC同时应用搜索过滤
const allNpcs = computed(() => npcStore.npcs.filter(matchesSearch))
const friendlyNpcs = computed(() => {
return npcStore.npcs.filter(npc => {
if (!matchesSearch(npc)) return false
const relation = getRelation(npc.id)
return relation?.status === RelationStatus.Friendly
})
})
const neutralNpcs = computed(() => {
return npcStore.npcs.filter(npc => {
if (!matchesSearch(npc)) return false
const relation = getRelation(npc.id)
return !relation || relation.status === RelationStatus.Neutral
})
})
const hostileNpcs = computed(() => {
return npcStore.npcs.filter(npc => {
if (!matchesSearch(npc)) return false
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[] = []
const maxVisible = 3
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
let start = currentPageNum - 1
let end = currentPageNum + 1
if (start < 1) {
start = 1
end = maxVisible
}
if (end > totalPages) {
end = totalPages
start = totalPages - maxVisible + 1
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
}
return pages
}
// 当前标签页的分页数据(用于固定底部分页)
const currentTotalPages = computed(() => {
switch (activeTab.value) {
case 'friendly':
return totalPagesFriendly.value
case 'neutral':
return totalPagesNeutral.value
case 'hostile':
return totalPagesHostile.value
default:
return totalPagesAll.value
}
})
const currentPageValue = computed({
get: () => currentPage.value[activeTab.value] || 1,
set: (val: number) => {
currentPage.value[activeTab.value] = val
}
})
// ========== NPC互动面板相关 ==========
// 获取当前时间戳
const now = computed(() => Date.now())
// 有效的贸易提议(未过期)
const activeTradeOffers = computed(() => {
return (gameStore.player.tradeOffers || []).filter(offer => offer.expiresAt > now.value)
})
// 未读的情报报告
const unreadIntelReports = computed(() => {
return (gameStore.player.intelReports || []).filter(report => !report.read)
})
// 有效的联合攻击邀请(未过期)
const activeJointAttackInvites = computed(() => {
return (gameStore.player.jointAttackInvites || []).filter(invite => invite.expiresAt > now.value)
})
// 是否有NPC互动数据
const hasNpcInteractions = computed(() => {
return (
(gameStore.player.tradeOffers?.length || 0) > 0 ||
(gameStore.player.intelReports?.length || 0) > 0 ||
(gameStore.player.jointAttackInvites?.length || 0) > 0
)
})
// 是否有有效的互动内容
const hasActiveInteractions = computed(() => {
return activeTradeOffers.value.length > 0 || unreadIntelReports.value.length > 0 || activeJointAttackInvites.value.length > 0
})
// 总互动数量(用于显示徽章)
const totalInteractionCount = computed(() => {
return activeTradeOffers.value.length + unreadIntelReports.value.length + activeJointAttackInvites.value.length
})
// 获取NPC名称
const getNpcName = (npcId: string): string => {
const npc = npcStore.npcs.find(n => n.id === npcId)
return npc?.name || npcId
}
// 格式化资源显示
// 格式化资源(兼容新旧格式)
const formatResources = (resources: Resources | { type: string; amount: number }): string => {
// 新格式:{ type: 'metal', amount: 1000 }
if ('type' in resources && 'amount' in resources) {
const typeLabels: Record<string, string> = {
metal: 'M',
crystal: 'C',
deuterium: 'D'
}
return `${Math.floor(resources.amount).toLocaleString()} ${typeLabels[resources.type] || resources.type}`
}
// 旧格式:{ metal: 1000, crystal: 0, deuterium: 0 }
const parts: string[] = []
if ((resources as Resources).metal > 0) parts.push(`${Math.floor((resources as Resources).metal).toLocaleString()} M`)
if ((resources as Resources).crystal > 0) parts.push(`${Math.floor((resources as Resources).crystal).toLocaleString()} C`)
if ((resources as Resources).deuterium > 0) parts.push(`${Math.floor((resources as Resources).deuterium).toLocaleString()} D`)
return parts.join(' / ') || '-'
}
// 格式化舰队信息
const formatFleetInfo = (fleetInfo: Record<string, number>): string => {
const parts: string[] = []
for (const [shipType, count] of Object.entries(fleetInfo)) {
if (count > 0) {
parts.push(`${shipType}: ${count}`)
}
}
return parts.join(', ') || '-'
}
// 格式化剩余时间
const formatTimeRemaining = (expiresAt: number): string => {
const remaining = expiresAt - now.value
if (remaining <= 0) return t('npcBehavior.trade.expired')
const minutes = Math.floor(remaining / 60000)
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
if (hours > 0) {
return `${hours}h ${mins}m`
}
return `${mins}m`
}
// 检查是否可以接受贸易(兼容新格式 { type, amount }
const canAcceptTrade = (offer: TradeOffer): boolean => {
const planet = gameStore.player.planets[0]
if (!planet) return false
// 新格式:{ type: 'metal', amount: 1000 }
const requestedType = offer.requestedResources.type
const requestedAmount = offer.requestedResources.amount
return planet.resources[requestedType] >= requestedAmount
}
// 接受贸易提议
const acceptTradeOffer = (offer: TradeOffer) => {
if (!canAcceptTrade(offer)) {
toast.error(t('npcBehavior.trade.acceptFailed'))
return
}
const planet = gameStore.player.planets[0]
if (!planet) return
// 新格式:{ type: 'metal', amount: 1000 }
const requestedType = offer.requestedResources.type
const requestedAmount = offer.requestedResources.amount
const offeredType = offer.offeredResources.type
const offeredAmount = offer.offeredResources.amount
// 扣除请求的资源
planet.resources[requestedType] -= requestedAmount
// 添加获得的资源
planet.resources[offeredType] += offeredAmount
// 移除贸易提议
const index = gameStore.player.tradeOffers?.indexOf(offer)
if (index !== undefined && index >= 0) {
gameStore.player.tradeOffers?.splice(index, 1)
}
// 提高与该NPC的好感度使用 npcId 而不是 fromNpcId
const npcRelation = npcStore.npcs.find(n => n.id === offer.npcId)?.relations?.[gameStore.player.id]
if (npcRelation) {
npcRelation.reputation += 10
}
toast.success(t('npcBehavior.trade.acceptSuccess'))
}
// 拒绝贸易提议
const declineTradeOffer = (offer: TradeOffer) => {
const index = gameStore.player.tradeOffers?.indexOf(offer)
if (index !== undefined && index >= 0) {
gameStore.player.tradeOffers?.splice(index, 1)
}
toast.info(t('npcBehavior.trade.declined'))
}
// 标记情报为已读
const markIntelAsRead = (intel: IntelReport) => {
intel.read = true
}
// 接受联合攻击邀请
const acceptJointAttack = (invite: JointAttackInvite) => {
// 这里可以添加联合攻击的逻辑
// 目前只是简单地移除邀请并显示提示
const index = gameStore.player.jointAttackInvites?.indexOf(invite)
if (index !== undefined && index >= 0) {
gameStore.player.jointAttackInvites?.splice(index, 1)
}
// 提高与该NPC的好感度使用 npcStore
const npcRelation = npcStore.npcs.find(n => n.id === invite.fromNpcId)?.relations?.[gameStore.player.id]
if (npcRelation) {
npcRelation.reputation += 15
}
toast.success(t('npcBehavior.jointAttack.acceptSuccess'))
}
// 拒绝联合攻击邀请
const declineJointAttack = (invite: JointAttackInvite) => {
const index = gameStore.player.jointAttackInvites?.indexOf(invite)
if (index !== undefined && index >= 0) {
gameStore.player.jointAttackInvites?.splice(index, 1)
}
toast.info(t('npcBehavior.jointAttack.declined'))
}
</script>
<style>
/* NPC卡片高亮动画 - 不使用scoped以便动态添加类生效 */
.npc-highlight {
animation: highlight-pulse 1.5s ease-in-out 2 !important;
box-shadow: 0 0 0 3px var(--primary) !important;
border-radius: 0.5rem !important;
}
@keyframes highlight-pulse {
0%,
100% {
box-shadow: 0 0 0 3px var(--primary);
}
50% {
box-shadow: 0 0 0 6px var(--primary), 0 0 25px var(--primary);
}
}
</style>