mirror of
https://github.com/setube/ogame-vue-ts.git
synced 2026-05-12 16:05:12 +08:00
docs: 新增西班牙语和日语README并优化多语言文档
新增README-ES.md(西班牙语)和README-JA.md(日语)文档,完善多语言README互链。优化各语言README徽章、技术栈、外链格式及语言切换区,提升文档一致性与可读性。
This commit is contained in:
@@ -52,7 +52,7 @@
|
||||
<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>
|
||||
<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
|
||||
v-for="resourceType in costResourceTypes"
|
||||
@@ -118,13 +118,23 @@
|
||||
|
||||
<!-- 拆除信息提示 -->
|
||||
<div v-if="getBuildingLevel(buildingType) > 0" class="text-xs text-muted-foreground">
|
||||
<p>{{ t('buildingsView.demolishRefund') }}:</p>
|
||||
<p>{{ t('buildingsView.demolishRefund') }}</p>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<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 class="flex items-center gap-1.5" v-if="getDemolishRefund(buildingType).metal">
|
||||
<ResourceIcon type="metal" size="sm" />
|
||||
{{ formatNumber(getDemolishRefund(buildingType).metal) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5" v-if="getDemolishRefund(buildingType).crystal">
|
||||
<ResourceIcon type="crystal" size="sm" />
|
||||
{{ formatNumber(getDemolishRefund(buildingType).crystal) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5" v-if="getDemolishRefund(buildingType).deuterium">
|
||||
<ResourceIcon type="deuterium" size="sm" />
|
||||
{{ formatNumber(getDemolishRefund(buildingType).deuterium) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5" v-if="getDemolishRefund(buildingType).darkMatter">
|
||||
<ResourceIcon type="darkMatter" size="sm" />
|
||||
{{ formatNumber(getDemolishRefund(buildingType).darkMatter) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,6 +231,10 @@
|
||||
const alertDialogRequirements = ref<Array<{ name: string; requiredLevel: number; currentLevel: number; met: boolean }>>([])
|
||||
const alertDialogShowRequirements = ref(false)
|
||||
|
||||
// 防抖状态:防止快速点击
|
||||
const isProcessing = ref(false)
|
||||
const DEBOUNCE_DELAY = 300 // 防抖延迟(毫秒)
|
||||
|
||||
// 拆除确认对话框状态
|
||||
const demolishConfirmOpen = ref(false)
|
||||
const demolishConfirmMessage = ref('')
|
||||
@@ -276,6 +290,13 @@
|
||||
|
||||
// 升级建筑
|
||||
const handleUpgrade = (buildingType: BuildingType, event: MouseEvent) => {
|
||||
// 防抖:防止快速点击
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
setTimeout(() => {
|
||||
isProcessing.value = false
|
||||
}, DEBOUNCE_DELAY)
|
||||
|
||||
// 检查前置条件
|
||||
if (!checkUpgradeRequirements(buildingType)) {
|
||||
alertDialogTitle.value = t('common.requirementsNotMet')
|
||||
@@ -448,12 +469,11 @@
|
||||
|
||||
const handleDemolish = (buildingType: BuildingType) => {
|
||||
const refund = getDemolishRefund(buildingType)
|
||||
demolishConfirmMessage.value = `${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)}` : ''
|
||||
}`
|
||||
demolishConfirmMessage.value = `${t('buildingsView.demolishRefund')}
|
||||
${refund.metal ? `${t('resources.metal')}: ${formatNumber(refund.metal)}` : ''}
|
||||
${refund.crystal ? `${t('resources.crystal')}: ${formatNumber(refund.crystal)}` : ''}
|
||||
${refund.deuterium ? `${t('resources.deuterium')}: ${formatNumber(refund.deuterium)}` : ''}
|
||||
${refund.darkMatter ? `${t('resources.darkMatter')}: ${formatNumber(refund.darkMatter)}` : ''}`
|
||||
|
||||
pendingDemolishBuilding.value = buildingType
|
||||
demolishConfirmOpen.value = true
|
||||
|
||||
@@ -58,11 +58,7 @@
|
||||
</Card>
|
||||
|
||||
<!-- 任务地图 -->
|
||||
<QuestMap
|
||||
:quests="getChapterQuests(chapter.number)"
|
||||
:progress="campaignProgress"
|
||||
@select-quest="handleQuestSelect"
|
||||
/>
|
||||
<QuestMap :quests="getChapterQuests(chapter.number)" :progress="campaignProgress" @select-quest="handleQuestSelect" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -71,12 +67,7 @@
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'h-10 w-10 rounded-full flex items-center justify-center',
|
||||
getQuestStatusClass(selectedQuest.id)
|
||||
]"
|
||||
>
|
||||
<div :class="['h-10 w-10 rounded-full flex items-center justify-center', getQuestStatusClass(selectedQuest.id)]">
|
||||
<component :is="getQuestStatusIcon(selectedQuest.id)" class="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -88,18 +79,11 @@
|
||||
<CardDescription>{{ t(selectedQuest.descriptionKey) }}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="canStartQuest(selectedQuest.id)"
|
||||
@click="handleStartQuest(selectedQuest.id)"
|
||||
>
|
||||
<Button v-if="canStartQuest(selectedQuest.id)" @click="handleStartQuest(selectedQuest.id)">
|
||||
<Play class="h-4 w-4 mr-2" />
|
||||
{{ t('campaign.startQuest') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="canClaimRewards(selectedQuest.id)"
|
||||
@click="handleClaimRewards(selectedQuest.id)"
|
||||
variant="default"
|
||||
>
|
||||
<Button v-else-if="canClaimRewards(selectedQuest.id)" @click="handleClaimRewards(selectedQuest.id)" variant="default">
|
||||
<Gift class="h-4 w-4 mr-2" />
|
||||
{{ t('campaign.claimRewards') }}
|
||||
</Button>
|
||||
@@ -113,16 +97,12 @@
|
||||
{{ t('campaign.objectives') }}
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="objective in selectedQuest.objectives"
|
||||
:key="objective.id"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div v-for="objective in selectedQuest.objectives" :key="objective.id" class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'h-6 w-6 rounded-full flex items-center justify-center text-xs',
|
||||
isObjectiveCompleted(selectedQuest.id, objective.id)
|
||||
? 'bg-green-500 text-white'
|
||||
? 'bg-green-500 dark:bg-green-400 text-white'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
]"
|
||||
>
|
||||
@@ -170,12 +150,7 @@
|
||||
<Star class="h-3 w-3" />
|
||||
+{{ formatNumber(selectedQuest.rewards.points) }} {{ t('common.points') }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-for="(count, shipType) in selectedQuest.rewards.ships"
|
||||
:key="shipType"
|
||||
variant="outline"
|
||||
class="gap-1"
|
||||
>
|
||||
<Badge v-for="(count, shipType) in selectedQuest.rewards.ships" :key="shipType" variant="outline" class="gap-1">
|
||||
<Rocket class="h-3 w-3" />
|
||||
{{ count }}x {{ getShipName(shipType) }}
|
||||
</Badge>
|
||||
@@ -185,270 +160,315 @@
|
||||
</Card>
|
||||
|
||||
<!-- 剧情对话框 -->
|
||||
<StoryDialog
|
||||
v-if="showStoryDialog"
|
||||
:dialogues="currentDialogues"
|
||||
@close="handleDialogueClose"
|
||||
@choice="handleDialogueChoice"
|
||||
/>
|
||||
<StoryDialog v-if="showStoryDialog" :dialogues="currentDialogues" @close="handleDialogueClose" @choice="handleDialogueChoice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useNPCStore } from '@/stores/npcStore'
|
||||
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 { Progress } from '@/components/ui/progress'
|
||||
import ResourceIcon from '@/components/common/ResourceIcon.vue'
|
||||
import QuestMap from '@/components/campaign/QuestMap.vue'
|
||||
import StoryDialog from '@/components/campaign/StoryDialog.vue'
|
||||
import {
|
||||
Scroll,
|
||||
Check,
|
||||
Play,
|
||||
Gift,
|
||||
Target,
|
||||
Star,
|
||||
Rocket,
|
||||
Lock,
|
||||
Circle,
|
||||
CheckCircle2
|
||||
} from 'lucide-vue-next'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { MAIN_CAMPAIGN, getQuestsByChapter, getQuestById, getTotalQuestCount } from '@/config/campaignConfig'
|
||||
import * as campaignLogic from '@/logic/campaignLogic'
|
||||
import { QuestStatus, type CampaignQuestConfig, type StoryDialogue } from '@/types/game'
|
||||
import { SHIPS } from '@/config/gameConfig'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from '@/composables/useI18n'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useNPCStore } from '@/stores/npcStore'
|
||||
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 { Progress } from '@/components/ui/progress'
|
||||
import ResourceIcon from '@/components/common/ResourceIcon.vue'
|
||||
import QuestMap from '@/components/campaign/QuestMap.vue'
|
||||
import StoryDialog from '@/components/campaign/StoryDialog.vue'
|
||||
import { Scroll, Check, Play, Gift, Target, Star, Rocket, Lock, Circle, CheckCircle2 } from 'lucide-vue-next'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { MAIN_CAMPAIGN, getQuestsByChapter, getQuestById, getTotalQuestCount } from '@/config/campaignConfig'
|
||||
import * as campaignLogic from '@/logic/campaignLogic'
|
||||
import { QuestStatus, DiplomaticEventType, type CampaignQuestConfig, type StoryDialogue, type DialogueChoice } from '@/types/game'
|
||||
import { SHIPS } from '@/config/gameConfig'
|
||||
import { updateReputation, getOrCreateRelation } from '@/logic/diplomaticLogic'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const { t } = useI18n()
|
||||
const gameStore = useGameStore()
|
||||
const npcStore = useNPCStore()
|
||||
const { t } = useI18n()
|
||||
const gameStore = useGameStore()
|
||||
const npcStore = useNPCStore()
|
||||
|
||||
// 初始化战役进度
|
||||
onMounted(() => {
|
||||
if (!gameStore.player.campaignProgress) {
|
||||
gameStore.player.campaignProgress = campaignLogic.initializeCampaignProgress(gameStore.player)
|
||||
}
|
||||
})
|
||||
|
||||
// 响应式状态
|
||||
const activeChapter = ref('1')
|
||||
const selectedQuestId = ref<string | null>(null)
|
||||
const showStoryDialog = ref(false)
|
||||
const currentDialogues = ref<StoryDialogue[]>([])
|
||||
const pendingAction = ref<'start' | 'claim' | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const chapters = computed(() => MAIN_CAMPAIGN.chapters)
|
||||
|
||||
const campaignProgress = computed(() => gameStore.player.campaignProgress)
|
||||
|
||||
const currentChapter = computed(() => campaignProgress.value?.currentChapter || 1)
|
||||
|
||||
const totalProgress = computed(() => {
|
||||
if (!campaignProgress.value) return 0
|
||||
return campaignLogic.calculateCampaignProgress(campaignProgress.value)
|
||||
})
|
||||
|
||||
const totalQuestCount = computed(() => getTotalQuestCount())
|
||||
|
||||
const completedQuestCount = computed(() => campaignProgress.value?.completedQuests.length || 0)
|
||||
|
||||
const selectedQuest = computed(() => {
|
||||
if (!selectedQuestId.value) return null
|
||||
return getQuestById(selectedQuestId.value)
|
||||
})
|
||||
|
||||
// 获取章节任务
|
||||
const getChapterQuests = (chapterNumber: number): CampaignQuestConfig[] => {
|
||||
return getQuestsByChapter(chapterNumber)
|
||||
}
|
||||
|
||||
// 获取章节进度
|
||||
const getChapterProgress = (chapterNumber: number): number => {
|
||||
if (!campaignProgress.value) return 0
|
||||
return campaignLogic.calculateChapterProgress(campaignProgress.value, chapterNumber)
|
||||
}
|
||||
|
||||
// 获取任务状态
|
||||
const getQuestStatus = (questId: string): QuestStatus => {
|
||||
if (!campaignProgress.value) return QuestStatus.Locked
|
||||
return campaignLogic.getQuestStatus(campaignProgress.value, questId)
|
||||
}
|
||||
|
||||
// 获取任务状态样式
|
||||
const getQuestStatusClass = (questId: string): string => {
|
||||
const status = getQuestStatus(questId)
|
||||
switch (status) {
|
||||
case QuestStatus.Completed:
|
||||
return 'bg-green-500 text-white'
|
||||
case QuestStatus.Active:
|
||||
return 'bg-primary text-primary-foreground'
|
||||
case QuestStatus.Available:
|
||||
return 'bg-blue-500 text-white'
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取任务状态图标
|
||||
const getQuestStatusIcon = (questId: string) => {
|
||||
const status = getQuestStatus(questId)
|
||||
switch (status) {
|
||||
case QuestStatus.Completed:
|
||||
return CheckCircle2
|
||||
case QuestStatus.Active:
|
||||
return Circle
|
||||
case QuestStatus.Available:
|
||||
return Circle
|
||||
default:
|
||||
return Lock
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以开始任务
|
||||
const canStartQuest = (questId: string): boolean => {
|
||||
const status = getQuestStatus(questId)
|
||||
return status === QuestStatus.Available
|
||||
}
|
||||
|
||||
// 检查是否可以领取奖励
|
||||
const canClaimRewards = (questId: string): boolean => {
|
||||
const status = getQuestStatus(questId)
|
||||
const progress = campaignProgress.value?.questProgress[questId]
|
||||
return status === QuestStatus.Completed && !progress?.rewardsClaimed
|
||||
}
|
||||
|
||||
// 检查目标是否完成
|
||||
const isObjectiveCompleted = (questId: string, objectiveId: string): boolean => {
|
||||
const progress = campaignProgress.value?.questProgress[questId]
|
||||
return progress?.objectives[objectiveId]?.completed || false
|
||||
}
|
||||
|
||||
// 获取目标进度
|
||||
const getObjectiveProgress = (questId: string, objectiveId: string): number => {
|
||||
const progress = campaignProgress.value?.questProgress[questId]
|
||||
return progress?.objectives[objectiveId]?.current || 0
|
||||
}
|
||||
|
||||
// 获取舰船名称
|
||||
const getShipName = (shipType: string): string => {
|
||||
const ship = SHIPS[shipType as keyof typeof SHIPS]
|
||||
return ship?.name || shipType
|
||||
}
|
||||
|
||||
// 处理任务选择
|
||||
const handleQuestSelect = (questId: string) => {
|
||||
selectedQuestId.value = questId
|
||||
}
|
||||
|
||||
// 处理开始任务
|
||||
const handleStartQuest = (questId: string) => {
|
||||
const quest = getQuestById(questId)
|
||||
|
||||
// 如果有开场对话,先显示对话
|
||||
if (quest?.prologueDialogues && quest.prologueDialogues.length > 0) {
|
||||
currentDialogues.value = quest.prologueDialogues
|
||||
pendingAction.value = 'start'
|
||||
showStoryDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 直接开始任务
|
||||
executeStartQuest(questId)
|
||||
}
|
||||
|
||||
// 执行开始任务
|
||||
const executeStartQuest = (questId: string) => {
|
||||
const result = campaignLogic.startQuest(gameStore.player, questId)
|
||||
if (result.success) {
|
||||
toast.success(t('campaign.notifications.questStarted'))
|
||||
// 立即检查进度
|
||||
campaignLogic.checkAllActiveQuestsProgress(gameStore.player, npcStore.npcs)
|
||||
} else if (result.error) {
|
||||
toast.error(t(result.error))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理领取奖励
|
||||
const handleClaimRewards = (questId: string) => {
|
||||
const quest = getQuestById(questId)
|
||||
|
||||
// 如果有结束对话,先显示对话
|
||||
if (quest?.epilogueDialogues && quest.epilogueDialogues.length > 0) {
|
||||
currentDialogues.value = quest.epilogueDialogues
|
||||
pendingAction.value = 'claim'
|
||||
showStoryDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 直接领取奖励
|
||||
executeClaimRewards(questId)
|
||||
}
|
||||
|
||||
// 执行领取奖励
|
||||
const executeClaimRewards = (questId: string) => {
|
||||
const result = campaignLogic.claimQuestRewards(gameStore.player, questId)
|
||||
if (result.success) {
|
||||
toast.success(t('campaign.notifications.rewardsClaimed'))
|
||||
} else if (result.error) {
|
||||
toast.error(t(result.error))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对话关闭
|
||||
const handleDialogueClose = () => {
|
||||
showStoryDialog.value = false
|
||||
|
||||
if (pendingAction.value && selectedQuestId.value) {
|
||||
if (pendingAction.value === 'start') {
|
||||
executeStartQuest(selectedQuestId.value)
|
||||
} else if (pendingAction.value === 'claim') {
|
||||
executeClaimRewards(selectedQuestId.value)
|
||||
// 初始化战役进度
|
||||
onMounted(() => {
|
||||
if (!gameStore.player.campaignProgress) {
|
||||
gameStore.player.campaignProgress = campaignLogic.initializeCampaignProgress(gameStore.player)
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction.value = null
|
||||
currentDialogues.value = []
|
||||
}
|
||||
|
||||
// 处理对话选项选择
|
||||
const handleDialogueChoice = (choice: { effect?: string }) => {
|
||||
// TODO: 处理选择效果
|
||||
console.log('Dialogue choice:', choice)
|
||||
}
|
||||
|
||||
// 监听章节变化,自动选择第一个可用任务
|
||||
watch(activeChapter, (newChapter) => {
|
||||
const chapterQuests = getChapterQuests(parseInt(newChapter))
|
||||
const availableQuest = chapterQuests.find(quest => {
|
||||
const status = getQuestStatus(quest.id)
|
||||
return status === QuestStatus.Active || status === QuestStatus.Available
|
||||
})
|
||||
if (availableQuest) {
|
||||
selectedQuestId.value = availableQuest.id
|
||||
} else {
|
||||
const firstQuest = chapterQuests[0]
|
||||
if (firstQuest) {
|
||||
selectedQuestId.value = firstQuest.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 初始选择当前任务
|
||||
onMounted(() => {
|
||||
if (campaignProgress.value?.currentQuestId) {
|
||||
selectedQuestId.value = campaignProgress.value.currentQuestId
|
||||
const quest = getQuestById(campaignProgress.value.currentQuestId)
|
||||
if (quest) {
|
||||
activeChapter.value = quest.chapter.toString()
|
||||
// 响应式状态
|
||||
const activeChapter = ref('1')
|
||||
const selectedQuestId = ref<string | null>(null)
|
||||
const showStoryDialog = ref(false)
|
||||
const currentDialogues = ref<StoryDialogue[]>([])
|
||||
const pendingAction = ref<'start' | 'claim' | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const chapters = computed(() => MAIN_CAMPAIGN.chapters)
|
||||
|
||||
const campaignProgress = computed(() => gameStore.player.campaignProgress)
|
||||
|
||||
const currentChapter = computed(() => campaignProgress.value?.currentChapter || 1)
|
||||
|
||||
const totalProgress = computed(() => {
|
||||
if (!campaignProgress.value) return 0
|
||||
return campaignLogic.calculateCampaignProgress(campaignProgress.value)
|
||||
})
|
||||
|
||||
const totalQuestCount = computed(() => getTotalQuestCount())
|
||||
|
||||
const completedQuestCount = computed(() => campaignProgress.value?.completedQuests.length || 0)
|
||||
|
||||
const selectedQuest = computed(() => {
|
||||
if (!selectedQuestId.value) return null
|
||||
return getQuestById(selectedQuestId.value)
|
||||
})
|
||||
|
||||
// 获取章节任务
|
||||
const getChapterQuests = (chapterNumber: number): CampaignQuestConfig[] => {
|
||||
return getQuestsByChapter(chapterNumber)
|
||||
}
|
||||
|
||||
// 获取章节进度
|
||||
const getChapterProgress = (chapterNumber: number): number => {
|
||||
if (!campaignProgress.value) return 0
|
||||
return campaignLogic.calculateChapterProgress(campaignProgress.value, chapterNumber)
|
||||
}
|
||||
|
||||
// 获取任务状态
|
||||
const getQuestStatus = (questId: string): QuestStatus => {
|
||||
if (!campaignProgress.value) return QuestStatus.Locked
|
||||
return campaignLogic.getQuestStatus(campaignProgress.value, questId)
|
||||
}
|
||||
|
||||
// 获取任务状态样式
|
||||
const getQuestStatusClass = (questId: string): string => {
|
||||
const status = getQuestStatus(questId)
|
||||
switch (status) {
|
||||
case QuestStatus.Completed:
|
||||
return 'bg-green-500 dark:bg-green-400 text-white'
|
||||
case QuestStatus.Active:
|
||||
return 'bg-primary text-primary-foreground'
|
||||
case QuestStatus.Available:
|
||||
return 'bg-blue-500 dark:bg-blue-400 text-white'
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取任务状态图标
|
||||
const getQuestStatusIcon = (questId: string) => {
|
||||
const status = getQuestStatus(questId)
|
||||
switch (status) {
|
||||
case QuestStatus.Completed:
|
||||
return CheckCircle2
|
||||
case QuestStatus.Active:
|
||||
return Circle
|
||||
case QuestStatus.Available:
|
||||
return Circle
|
||||
default:
|
||||
return Lock
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以开始任务
|
||||
const canStartQuest = (questId: string): boolean => {
|
||||
const status = getQuestStatus(questId)
|
||||
return status === QuestStatus.Available
|
||||
}
|
||||
|
||||
// 检查是否可以领取奖励
|
||||
const canClaimRewards = (questId: string): boolean => {
|
||||
const status = getQuestStatus(questId)
|
||||
const progress = campaignProgress.value?.questProgress[questId]
|
||||
return status === QuestStatus.Completed && !progress?.rewardsClaimed
|
||||
}
|
||||
|
||||
// 检查目标是否完成
|
||||
const isObjectiveCompleted = (questId: string, objectiveId: string): boolean => {
|
||||
const progress = campaignProgress.value?.questProgress[questId]
|
||||
return progress?.objectives[objectiveId]?.completed || false
|
||||
}
|
||||
|
||||
// 获取目标进度
|
||||
const getObjectiveProgress = (questId: string, objectiveId: string): number => {
|
||||
const progress = campaignProgress.value?.questProgress[questId]
|
||||
return progress?.objectives[objectiveId]?.current || 0
|
||||
}
|
||||
|
||||
// 获取舰船名称
|
||||
const getShipName = (shipType: string): string => {
|
||||
const ship = SHIPS[shipType as keyof typeof SHIPS]
|
||||
return ship?.name || shipType
|
||||
}
|
||||
|
||||
// 处理任务选择
|
||||
const handleQuestSelect = (questId: string) => {
|
||||
selectedQuestId.value = questId
|
||||
}
|
||||
|
||||
// 处理开始任务
|
||||
const handleStartQuest = (questId: string) => {
|
||||
const quest = getQuestById(questId)
|
||||
|
||||
// 如果有开场对话,先显示对话
|
||||
if (quest?.prologueDialogues && quest.prologueDialogues.length > 0) {
|
||||
currentDialogues.value = quest.prologueDialogues
|
||||
pendingAction.value = 'start'
|
||||
showStoryDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 直接开始任务
|
||||
executeStartQuest(questId)
|
||||
}
|
||||
|
||||
// 执行开始任务
|
||||
const executeStartQuest = (questId: string) => {
|
||||
const result = campaignLogic.startQuest(gameStore.player, questId)
|
||||
if (result.success) {
|
||||
toast.success(t('campaign.notifications.questStarted'))
|
||||
// 立即检查进度
|
||||
campaignLogic.checkAllActiveQuestsProgress(gameStore.player, npcStore.npcs)
|
||||
} else if (result.error) {
|
||||
toast.error(t(result.error))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理领取奖励
|
||||
const handleClaimRewards = (questId: string) => {
|
||||
const quest = getQuestById(questId)
|
||||
|
||||
// 如果有结束对话,先显示对话
|
||||
if (quest?.epilogueDialogues && quest.epilogueDialogues.length > 0) {
|
||||
currentDialogues.value = quest.epilogueDialogues
|
||||
pendingAction.value = 'claim'
|
||||
showStoryDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 直接领取奖励
|
||||
executeClaimRewards(questId)
|
||||
}
|
||||
|
||||
// 执行领取奖励
|
||||
const executeClaimRewards = (questId: string) => {
|
||||
const result = campaignLogic.claimQuestRewards(gameStore.player, questId)
|
||||
if (result.success) {
|
||||
toast.success(t('campaign.notifications.rewardsClaimed'))
|
||||
} else if (result.error) {
|
||||
toast.error(t(result.error))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理对话关闭
|
||||
const handleDialogueClose = () => {
|
||||
showStoryDialog.value = false
|
||||
|
||||
if (pendingAction.value && selectedQuestId.value) {
|
||||
if (pendingAction.value === 'start') {
|
||||
executeStartQuest(selectedQuestId.value)
|
||||
} else if (pendingAction.value === 'claim') {
|
||||
executeClaimRewards(selectedQuestId.value)
|
||||
}
|
||||
}
|
||||
|
||||
pendingAction.value = null
|
||||
currentDialogues.value = []
|
||||
}
|
||||
|
||||
// 处理对话选项选择
|
||||
const handleDialogueChoice = (choice: DialogueChoice) => {
|
||||
if (!choice.effect) return
|
||||
|
||||
const DEFAULT_REPUTATION_CHANGE = 10
|
||||
|
||||
switch (choice.effect) {
|
||||
case 'reputation_up':
|
||||
case 'reputation_down': {
|
||||
// 需要指定NPC ID才能修改声望
|
||||
if (!choice.npcId) {
|
||||
return
|
||||
}
|
||||
|
||||
const npc = npcStore.npcs.find(n => n.id === choice.npcId)
|
||||
if (!npc) {
|
||||
return
|
||||
}
|
||||
|
||||
// 确保 relations 对象存在
|
||||
if (!npc.relations) {
|
||||
npc.relations = {}
|
||||
}
|
||||
|
||||
const change =
|
||||
choice.effect === 'reputation_up' ? choice.value ?? DEFAULT_REPUTATION_CHANGE : -(choice.value ?? DEFAULT_REPUTATION_CHANGE)
|
||||
|
||||
const relation = getOrCreateRelation(npc.relations, npc.id, gameStore.player.id)
|
||||
npc.relations[gameStore.player.id] = updateReputation(
|
||||
relation,
|
||||
change,
|
||||
DiplomaticEventType.CampaignChoice,
|
||||
t('campaign.dialogue.choiceEffect')
|
||||
)
|
||||
|
||||
// 显示提示
|
||||
if (change > 0) {
|
||||
toast.success(t('campaign.notifications.reputationUp', { npcName: npc.name, value: change }))
|
||||
} else {
|
||||
toast.warning(t('campaign.notifications.reputationDown', { npcName: npc.name, value: Math.abs(change) }))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'unlock_branch': {
|
||||
// 解锁分支任务
|
||||
if (!choice.branchId) {
|
||||
return
|
||||
}
|
||||
|
||||
// 将分支任务ID添加到已解锁分支列表
|
||||
if (!gameStore.player.campaignProgress) return
|
||||
|
||||
if (!gameStore.player.campaignProgress.unlockedBranches) {
|
||||
gameStore.player.campaignProgress.unlockedBranches = []
|
||||
}
|
||||
|
||||
if (!gameStore.player.campaignProgress.unlockedBranches.includes(choice.branchId)) {
|
||||
gameStore.player.campaignProgress.unlockedBranches.push(choice.branchId)
|
||||
toast.success(t('campaign.notifications.branchUnlocked'))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听章节变化,自动选择第一个可用任务
|
||||
watch(activeChapter, newChapter => {
|
||||
const chapterQuests = getChapterQuests(parseInt(newChapter))
|
||||
const availableQuest = chapterQuests.find(quest => {
|
||||
const status = getQuestStatus(quest.id)
|
||||
return status === QuestStatus.Active || status === QuestStatus.Available
|
||||
})
|
||||
if (availableQuest) {
|
||||
selectedQuestId.value = availableQuest.id
|
||||
} else {
|
||||
const firstQuest = chapterQuests[0]
|
||||
if (firstQuest) {
|
||||
selectedQuestId.value = firstQuest.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 初始选择当前任务
|
||||
onMounted(() => {
|
||||
if (campaignProgress.value?.currentQuestId) {
|
||||
selectedQuestId.value = campaignProgress.value.currentQuestId
|
||||
const quest = getQuestById(campaignProgress.value.currentQuestId)
|
||||
if (quest) {
|
||||
activeChapter.value = quest.chapter.toString()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -217,6 +217,10 @@
|
||||
const alertDialogTitle = ref('')
|
||||
const alertDialogMessage = ref('')
|
||||
|
||||
// 防抖状态:防止快速点击
|
||||
const isProcessing = ref(false)
|
||||
const DEBOUNCE_DELAY = 300 // 防抖延迟(毫秒)
|
||||
|
||||
// 资源类型配置(用于成本显示)
|
||||
const costResourceTypes = [
|
||||
{ key: 'metal' as const },
|
||||
@@ -262,6 +266,13 @@
|
||||
|
||||
// 建造防御设施
|
||||
const handleBuild = (defenseType: DefenseType, event: MouseEvent) => {
|
||||
// 防抖:防止快速点击
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
setTimeout(() => {
|
||||
isProcessing.value = false
|
||||
}, DEBOUNCE_DELAY)
|
||||
|
||||
const quantity = quantities.value[defenseType]
|
||||
if (quantity <= 0) {
|
||||
alertDialogTitle.value = t('defenseView.inputError')
|
||||
|
||||
@@ -81,7 +81,10 @@
|
||||
</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}`) : ''">
|
||||
<span
|
||||
class="font-medium"
|
||||
:title="diagnostic.aiType ? t(`diplomacy.diagnostic.aiTypeDescriptions.${diagnostic.aiType}`) : ''"
|
||||
>
|
||||
{{ diagnostic.aiType ? t(`diplomacy.diagnostic.aiTypes.${diagnostic.aiType}`) : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -178,6 +181,133 @@
|
||||
</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" />
|
||||
@@ -373,11 +503,14 @@
|
||||
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,
|
||||
@@ -390,9 +523,22 @@
|
||||
import NpcRelationCard from '@/components/npc/NpcRelationCard.vue'
|
||||
import NpcRelationRow from '@/components/npc/NpcRelationRow.vue'
|
||||
import { RelationStatus } from '@/types/game'
|
||||
import type { DiplomaticRelation } 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 } from 'lucide-vue-next'
|
||||
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()
|
||||
@@ -402,6 +548,9 @@
|
||||
|
||||
const activeTab = ref('all')
|
||||
|
||||
// NPC互动面板状态
|
||||
const interactionPanelOpen = ref(true)
|
||||
|
||||
// 视图模式: 'card' | 'list'
|
||||
const viewMode = ref<'card' | 'list'>('list')
|
||||
|
||||
@@ -715,6 +864,188 @@
|
||||
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>
|
||||
|
||||
@@ -60,9 +60,6 @@
|
||||
<span class="font-medium">{{ preset.name }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground mt-1 flex flex-wrap gap-2">
|
||||
<span v-if="preset.targetPosition">
|
||||
[{{ preset.targetPosition.galaxy }}:{{ preset.targetPosition.system }}:{{ preset.targetPosition.position }}]
|
||||
</span>
|
||||
<span v-if="preset.missionType">
|
||||
{{ getMissionName(preset.missionType) }}
|
||||
</span>
|
||||
@@ -232,7 +229,7 @@
|
||||
<!-- 赠送模式切换(仅当目标是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" />
|
||||
<Checkbox id="gift-mode" v-model:checked="isGiftMode" />
|
||||
<Label for="gift-mode" class="flex items-center gap-2 cursor-pointer">
|
||||
<Gift class="h-4 w-4" />
|
||||
{{ t('fleetView.giftMode') }}
|
||||
@@ -874,7 +871,7 @@
|
||||
const isGiftMode = ref(false)
|
||||
|
||||
// 舰队预设相关状态
|
||||
const MAX_PRESETS = 3
|
||||
const MAX_PRESETS = 5
|
||||
const editingPresetId = ref<string | null>(null)
|
||||
const editingPresetName = ref('')
|
||||
const showPresetNameDialog = ref(false)
|
||||
@@ -940,11 +937,7 @@
|
||||
id: generatePresetId(),
|
||||
name: editingPresetName.value.trim(),
|
||||
fleet: fleetToSave,
|
||||
targetPosition: {
|
||||
galaxy: targetPosition.value.galaxy,
|
||||
system: targetPosition.value.system,
|
||||
position: targetPosition.value.position
|
||||
},
|
||||
// 不再保存坐标,预设只保存舰队配置
|
||||
missionType: selectedMission.value,
|
||||
cargo: cargoToSave
|
||||
}
|
||||
@@ -966,10 +959,7 @@
|
||||
selectedFleet.value[key as ShipType] = preset.fleet[key as ShipType] || 0
|
||||
})
|
||||
|
||||
// 加载目标坐标
|
||||
if (preset.targetPosition) {
|
||||
targetPosition.value = { ...preset.targetPosition }
|
||||
}
|
||||
// 不再加载坐标,保留用户当前输入的坐标
|
||||
|
||||
// 加载任务类型
|
||||
if (preset.missionType) {
|
||||
@@ -1028,11 +1018,7 @@
|
||||
id: existingPreset.id,
|
||||
name: existingPreset.name,
|
||||
fleet: fleetToSave,
|
||||
targetPosition: {
|
||||
galaxy: targetPosition.value.galaxy,
|
||||
system: targetPosition.value.system,
|
||||
position: targetPosition.value.position
|
||||
},
|
||||
// 不再保存坐标
|
||||
missionType: selectedMission.value,
|
||||
cargo: cargoToSave
|
||||
}
|
||||
|
||||
@@ -738,10 +738,16 @@
|
||||
|
||||
<!-- 导弹攻击对话框 -->
|
||||
<Dialog :open="missileDialogOpen" @update:open="missileDialogOpen = $event">
|
||||
<DialogContent>
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('galaxyView.missileAttackTitle') }}</DialogTitle>
|
||||
<DialogDescription v-if="missileTargetPlanet">
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<div class="p-2 rounded-lg bg-destructive/10">
|
||||
<Rocket class="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
{{ t('galaxyView.missileAttackTitle') }}
|
||||
</DialogTitle>
|
||||
<DialogDescription v-if="missileTargetPlanet" class="flex items-center gap-2 pt-1">
|
||||
<MapPin class="h-4 w-4 text-muted-foreground" />
|
||||
{{
|
||||
t('galaxyView.missileAttackMessage').replace(
|
||||
'{coordinates}',
|
||||
@@ -751,41 +757,78 @@
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="gameStore.currentPlanet && missileTargetPlanet" class="space-y-4">
|
||||
<div v-if="gameStore.currentPlanet && missileTargetPlanet" class="space-y-5 py-2">
|
||||
<!-- 导弹数量输入 -->
|
||||
<div class="space-y-2">
|
||||
<Label>{{ t('galaxyView.missileCount') }}</Label>
|
||||
<Input
|
||||
v-model.number="missileCount"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="gameStore.currentPlanet.defense['interplanetaryMissile'] || 0"
|
||||
/>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('galaxyView.availableMissiles') }}: {{ gameStore.currentPlanet.defense['interplanetaryMissile'] || 0 }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{{ t('galaxyView.missileCount') }}</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<Input
|
||||
v-model.number="missileCount"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="gameStore.currentPlanet.defense['interplanetaryMissile'] || 0"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" @click="missileCount = gameStore.currentPlanet?.defense['interplanetaryMissile'] || 0">
|
||||
{{ t('fleetView.all') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Crosshair class="h-4 w-4" />
|
||||
<span>{{ t('galaxyView.availableMissiles') }}:</span>
|
||||
<span class="font-medium text-foreground">{{ gameStore.currentPlanet.defense['interplanetaryMissile'] || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 射程和距离信息 -->
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{{ t('galaxyView.missileRange') }}:</span>
|
||||
<span>{{ calculateMissileRange() }} {{ t('galaxyView.systems') }}</span>
|
||||
<!-- 任务信息卡片 -->
|
||||
<div class="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Target class="h-4 w-4" />
|
||||
<span>{{ t('galaxyView.missileRange') }}</span>
|
||||
</div>
|
||||
<span class="font-medium">{{ calculateMissileRange() }} {{ t('galaxyView.systems') }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{{ t('galaxyView.distance') }}:</span>
|
||||
<span>{{ calculateDistance(missileTargetPlanet) }} {{ t('galaxyView.systems') }}</span>
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Navigation class="h-4 w-4" />
|
||||
<span>{{ t('galaxyView.distance') }}</span>
|
||||
</div>
|
||||
<span class="font-medium">{{ formatDistance(calculateDistance(missileTargetPlanet)) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{{ t('galaxyView.flightTime') }}:</span>
|
||||
<span>{{ formatFlightTime(calculateDistance(missileTargetPlanet)) }}</span>
|
||||
<Separator />
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock class="h-4 w-4" />
|
||||
<span>{{ t('galaxyView.flightTime') }}</span>
|
||||
</div>
|
||||
<span class="font-medium">{{ formatFlightTime(calculateDistance(missileTargetPlanet)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 超出射程警告 -->
|
||||
<div
|
||||
v-if="calculateDistance(missileTargetPlanet) > calculateMissileRange()"
|
||||
class="flex items-center gap-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm"
|
||||
>
|
||||
<AlertTriangle class="h-4 w-4 shrink-0" />
|
||||
<span>{{ t('galaxyView.outOfRange') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="missileDialogOpen = false">{{ t('galaxyView.cancel') }}</Button>
|
||||
<Button @click="launchMissileAttack">{{ t('galaxyView.launchMissile') }}</Button>
|
||||
<DialogFooter class="gap-3">
|
||||
<Button variant="outline" @click="missileDialogOpen = false">
|
||||
{{ t('galaxyView.cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="launchMissileAttack"
|
||||
:disabled="!missileCount || missileCount < 1 || calculateDistance(missileTargetPlanet!) > calculateMissileRange()"
|
||||
>
|
||||
<Rocket class="h-4 w-4 mr-2" />
|
||||
{{ t('galaxyView.launchMissile') }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -915,6 +958,7 @@
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -927,7 +971,25 @@
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import ResourceIcon from '@/components/common/ResourceIcon.vue'
|
||||
import { Home, Eye, Sword, Rocket, Recycle, Gift, Globe, Bomb, Moon, Radar, Mountain } from 'lucide-vue-next'
|
||||
import {
|
||||
Home,
|
||||
Eye,
|
||||
Sword,
|
||||
Rocket,
|
||||
Recycle,
|
||||
Gift,
|
||||
Globe,
|
||||
Bomb,
|
||||
Moon,
|
||||
Radar,
|
||||
Mountain,
|
||||
MapPin,
|
||||
Crosshair,
|
||||
Target,
|
||||
Navigation,
|
||||
Clock,
|
||||
AlertTriangle
|
||||
} from 'lucide-vue-next'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import * as gameLogic from '@/logic/gameLogic'
|
||||
import * as moonLogic from '@/logic/moonLogic'
|
||||
@@ -1326,16 +1388,23 @@
|
||||
}
|
||||
|
||||
// 计算到目标的距离
|
||||
const calculateDistance = (target: Planet) => {
|
||||
if (!gameStore.currentPlanet) return 0
|
||||
const calculateDistance = (target: Planet | null): number => {
|
||||
if (!gameStore.currentPlanet || !target) return 0
|
||||
const from = gameStore.currentPlanet.position
|
||||
const to = target.position
|
||||
if (from.galaxy !== to.galaxy) return Infinity
|
||||
return Math.abs(from.system - to.system)
|
||||
}
|
||||
|
||||
// 格式化距离显示
|
||||
const formatDistance = (distance: number): string => {
|
||||
if (!isFinite(distance)) return t('galaxyView.outOfRange')
|
||||
return `${distance} ${t('galaxyView.systems')}`
|
||||
}
|
||||
|
||||
// 格式化飞行时间
|
||||
const formatFlightTime = (distance: number) => {
|
||||
const formatFlightTime = (distance: number): string => {
|
||||
if (!isFinite(distance)) return t('galaxyView.outOfRange')
|
||||
const seconds = 30 + distance * 60
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
|
||||
@@ -143,13 +143,16 @@
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<!-- NPC相关消息(活动、礼物、被拒绝) -->
|
||||
<!-- NPC相关消息(活动、礼物、被拒绝、贸易提议、情报、联合攻击邀请) -->
|
||||
<TabsContent value="npc" class="mt-4 space-y-2 pb-20">
|
||||
<Empty
|
||||
v-if="
|
||||
sortedNPCActivityNotifications.length === 0 &&
|
||||
sortedGiftNotifications.length === 0 &&
|
||||
sortedGiftRejectedNotifications.length === 0
|
||||
sortedGiftRejectedNotifications.length === 0 &&
|
||||
sortedTradeOffers.length === 0 &&
|
||||
sortedIntelReports.length === 0 &&
|
||||
sortedJointAttackInvites.length === 0
|
||||
"
|
||||
class="border rounded-lg"
|
||||
>
|
||||
@@ -159,6 +162,143 @@
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
|
||||
<!-- 贸易提议 -->
|
||||
<Card
|
||||
v-for="offer in sortedTradeOffers"
|
||||
:key="offer.id"
|
||||
@click="openTradeOfferDialog(offer)"
|
||||
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">
|
||||
<ArrowLeftRight class="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<CardTitle class="text-base sm:text-lg">{{ t('npcBehavior.trade.title') }}</CardTitle>
|
||||
<Badge variant="default" class="text-xs">{{ t('messagesView.pending') }}</Badge>
|
||||
<Badge v-if="isOfferExpired(offer)" variant="destructive" class="text-xs">
|
||||
{{ t('npcBehavior.trade.expired') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button @click.stop="deleteTradeOffer(offer.id)" variant="ghost" size="icon" class="h-8 w-8 shrink-0">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription class="text-xs sm:text-sm">
|
||||
{{ getNpcNameById(offer.npcId) }} · {{ formatDate(offer.timestamp) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1 flex items-center gap-1">
|
||||
<span class="text-green-600 dark:text-green-400">{{ t('npcBehavior.trade.offers') }}:</span>
|
||||
<template v-if="getResourceInfo(offer.offeredResources)">
|
||||
<ResourceIcon :type="getResourceInfo(offer.offeredResources)!.type" size="sm" />
|
||||
<NumberWithTooltip :value="getResourceInfo(offer.offeredResources)!.amount" />
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-1">
|
||||
<span class="text-red-600 dark:text-red-400">{{ t('npcBehavior.trade.requests') }}:</span>
|
||||
<template v-if="getResourceInfo(offer.requestedResources)">
|
||||
<ResourceIcon :type="getResourceInfo(offer.requestedResources)!.type" size="sm" />
|
||||
<NumberWithTooltip :value="getResourceInfo(offer.requestedResources)!.amount" />
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<Button
|
||||
@click.stop="acceptTradeOffer(offer)"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:disabled="isOfferExpired(offer) || !canAcceptTrade(offer)"
|
||||
>
|
||||
{{ t('npcBehavior.trade.accept') }}
|
||||
</Button>
|
||||
<Button @click.stop="declineTradeOffer(offer)" variant="outline" size="sm" class="flex-1">
|
||||
{{ t('npcBehavior.trade.decline') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- 情报报告 -->
|
||||
<Card
|
||||
v-for="intel in sortedIntelReports"
|
||||
:key="intel.id"
|
||||
@click="openIntelReportDialog(intel)"
|
||||
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">
|
||||
<FileText class="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<CardTitle class="text-base sm:text-lg">{{ t('npcBehavior.intel.title') }}</CardTitle>
|
||||
<Badge v-if="!intel.read" variant="default" class="text-xs">{{ t('messagesView.unread') }}</Badge>
|
||||
<Badge variant="outline" class="text-xs">{{ t(`npcBehavior.intel.types.${intel.intelType}`) }}</Badge>
|
||||
</div>
|
||||
<Button @click.stop="deleteIntelReport(intel.id)" variant="ghost" size="icon" class="h-8 w-8 shrink-0">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription class="text-xs sm:text-sm">
|
||||
{{ t('npcBehavior.intel.from') }}: {{ getNpcNameById(intel.fromNpcId) }} → {{ t('npcBehavior.intel.target') }}:
|
||||
{{ getNpcNameById(intel.targetNpcId) }} ·
|
||||
{{ formatDate(intel.timestamp) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<!-- 联合攻击邀请 -->
|
||||
<Card
|
||||
v-for="invite in sortedJointAttackInvites"
|
||||
:key="invite.id"
|
||||
@click="openJointAttackDialog(invite)"
|
||||
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">
|
||||
<Swords class="h-4 w-4 shrink-0 text-red-500" />
|
||||
<CardTitle class="text-base sm:text-lg">{{ t('npcBehavior.jointAttack.title') }}</CardTitle>
|
||||
<Badge variant="default" class="text-xs">{{ t('messagesView.pending') }}</Badge>
|
||||
<Badge v-if="isInviteExpired(invite)" variant="destructive" class="text-xs">
|
||||
{{ t('npcBehavior.jointAttack.expired') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button @click.stop="deleteJointAttackInvite(invite.id)" variant="ghost" size="icon" class="h-8 w-8 shrink-0">
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription class="text-xs sm:text-sm">
|
||||
{{ getNpcNameById(invite.fromNpcId) }} → {{ getNpcNameById(invite.targetNpcId) }} ({{ invite.targetNpcName }}) ·
|
||||
{{ formatDate(invite.timestamp) }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>{{ t('npcBehavior.jointAttack.lootShare') }}: {{ (invite.expectedLootRatio * 100).toFixed(0) }}%</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<Button
|
||||
@click.stop="acceptJointAttack(invite)"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:disabled="isInviteExpired(invite)"
|
||||
>
|
||||
{{ t('npcBehavior.jointAttack.accept') }}
|
||||
</Button>
|
||||
<Button @click.stop="declineJointAttack(invite)" variant="outline" size="sm" class="flex-1">
|
||||
{{ t('npcBehavior.jointAttack.decline') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- NPC活动通知 -->
|
||||
<Card
|
||||
v-for="notification in sortedNPCActivityNotifications"
|
||||
@@ -364,8 +504,25 @@
|
||||
import SpiedNotificationDialog from '@/components/dialogs/SpiedNotificationDialog.vue'
|
||||
import MissionReportDialog from '@/components/dialogs/MissionReportDialog.vue'
|
||||
import NPCActivityDialog from '@/components/dialogs/NPCActivityDialog.vue'
|
||||
import ResourceIcon from '@/components/common/ResourceIcon.vue'
|
||||
import NumberWithTooltip from '@/components/common/NumberWithTooltip.vue'
|
||||
import { formatDate } from '@/utils/format'
|
||||
import { X, Sword, Eye, AlertTriangle, Package, Recycle, Gift, Ban, Check, Users, Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
X,
|
||||
Sword,
|
||||
Eye,
|
||||
AlertTriangle,
|
||||
Package,
|
||||
Recycle,
|
||||
Gift,
|
||||
Ban,
|
||||
Check,
|
||||
Users,
|
||||
Trash2,
|
||||
ArrowLeftRight,
|
||||
FileText,
|
||||
Swords
|
||||
} from 'lucide-vue-next'
|
||||
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
|
||||
import type {
|
||||
BattleResult,
|
||||
@@ -374,7 +531,10 @@
|
||||
NPCActivityNotification,
|
||||
MissionReport,
|
||||
GiftNotification,
|
||||
GiftRejectedNotification
|
||||
GiftRejectedNotification,
|
||||
TradeOffer,
|
||||
IntelReport,
|
||||
JointAttackInvite
|
||||
} from '@/types/game'
|
||||
import { MissionType } from '@/types/game'
|
||||
import { useNPCStore } from '@/stores/npcStore'
|
||||
@@ -396,6 +556,9 @@
|
||||
| 'npcActivity'
|
||||
| 'giftNotifications'
|
||||
| 'giftRejected'
|
||||
| 'tradeOffers'
|
||||
| 'intelReports'
|
||||
| 'jointAttackInvites'
|
||||
const clearOptions = ref<Record<ClearOptionKey, boolean>>({
|
||||
battles: false,
|
||||
spyReports: false,
|
||||
@@ -403,7 +566,10 @@
|
||||
missionReports: false,
|
||||
npcActivity: false,
|
||||
giftNotifications: false,
|
||||
giftRejected: false
|
||||
giftRejected: false,
|
||||
tradeOffers: false,
|
||||
intelReports: false,
|
||||
jointAttackInvites: false
|
||||
})
|
||||
|
||||
// 清空消息选项配置
|
||||
@@ -422,7 +588,18 @@
|
||||
labelKey: 'clearGiftNotifications',
|
||||
count: gameStore.player.giftNotifications?.length || 0
|
||||
},
|
||||
{ key: 'giftRejected' as ClearOptionKey, labelKey: 'clearGiftRejected', count: gameStore.player.giftRejectedNotifications?.length || 0 }
|
||||
{
|
||||
key: 'giftRejected' as ClearOptionKey,
|
||||
labelKey: 'clearGiftRejected',
|
||||
count: gameStore.player.giftRejectedNotifications?.length || 0
|
||||
},
|
||||
{ key: 'tradeOffers' as ClearOptionKey, labelKey: 'clearTradeOffers', count: gameStore.player.tradeOffers?.length || 0 },
|
||||
{ key: 'intelReports' as ClearOptionKey, labelKey: 'clearIntelReports', count: gameStore.player.intelReports?.length || 0 },
|
||||
{
|
||||
key: 'jointAttackInvites' as ClearOptionKey,
|
||||
labelKey: 'clearJointAttackInvites',
|
||||
count: gameStore.player.jointAttackInvites?.length || 0
|
||||
}
|
||||
])
|
||||
|
||||
// 基础资源字段配置(用于显示资源列表)
|
||||
@@ -495,6 +672,15 @@
|
||||
if (clearOptions.value.giftRejected) {
|
||||
gameStore.player.giftRejectedNotifications = []
|
||||
}
|
||||
if (clearOptions.value.tradeOffers) {
|
||||
gameStore.player.tradeOffers = []
|
||||
}
|
||||
if (clearOptions.value.intelReports) {
|
||||
gameStore.player.intelReports = []
|
||||
}
|
||||
if (clearOptions.value.jointAttackInvites) {
|
||||
gameStore.player.jointAttackInvites = []
|
||||
}
|
||||
|
||||
// 重置选项
|
||||
clearOptions.value = {
|
||||
@@ -504,7 +690,10 @@
|
||||
missionReports: false,
|
||||
npcActivity: false,
|
||||
giftNotifications: false,
|
||||
giftRejected: false
|
||||
giftRejected: false,
|
||||
tradeOffers: false,
|
||||
intelReports: false,
|
||||
jointAttackInvites: false
|
||||
}
|
||||
|
||||
// 关闭popover
|
||||
@@ -608,12 +797,33 @@
|
||||
return allMissionReports.value.slice(start, end)
|
||||
})
|
||||
|
||||
// NPC标签页合并数据(活动通知 + 礼物通知 + 礼物被拒绝通知)
|
||||
// 贸易提议数据
|
||||
const allTradeOffers = computed(() => {
|
||||
if (!gameStore.player.tradeOffers) return []
|
||||
return [...gameStore.player.tradeOffers].sort((a, b) => b.timestamp - a.timestamp)
|
||||
})
|
||||
|
||||
// 情报报告数据
|
||||
const allIntelReports = computed(() => {
|
||||
if (!gameStore.player.intelReports) return []
|
||||
return [...gameStore.player.intelReports].sort((a, b) => b.timestamp - a.timestamp)
|
||||
})
|
||||
|
||||
// 联合攻击邀请数据
|
||||
const allJointAttackInvites = computed(() => {
|
||||
if (!gameStore.player.jointAttackInvites) return []
|
||||
return [...gameStore.player.jointAttackInvites].sort((a, b) => b.timestamp - a.timestamp)
|
||||
})
|
||||
|
||||
// NPC标签页合并数据(活动通知 + 礼物通知 + 礼物被拒绝通知 + 贸易提议 + 情报报告 + 联合攻击邀请)
|
||||
const allNPCTabItems = computed(() => {
|
||||
const activities = allNPCActivityNotifications.value.map(item => ({ ...item, type: 'activity' as const }))
|
||||
const gifts = allGiftNotifications.value.map(item => ({ ...item, type: 'gift' as const }))
|
||||
const rejections = allGiftRejectedNotifications.value.map(item => ({ ...item, type: 'rejection' as const }))
|
||||
return [...activities, ...gifts, ...rejections].sort((a, b) => b.timestamp - a.timestamp)
|
||||
const trades = allTradeOffers.value.map(item => ({ ...item, type: 'trade' as const }))
|
||||
const intels = allIntelReports.value.map(item => ({ ...item, type: 'intel' as const }))
|
||||
const jointAttacks = allJointAttackInvites.value.map(item => ({ ...item, type: 'jointAttack' as const }))
|
||||
return [...activities, ...gifts, ...rejections, ...trades, ...intels, ...jointAttacks].sort((a, b) => b.timestamp - a.timestamp)
|
||||
})
|
||||
|
||||
const npcTabTotalPages = computed(() => Math.ceil(allNPCTabItems.value.length / ITEMS_PER_PAGE))
|
||||
@@ -636,6 +846,18 @@
|
||||
return paginatedNPCTabItems.value.filter(item => item.type === 'rejection')
|
||||
})
|
||||
|
||||
const sortedTradeOffers = computed(() => {
|
||||
return paginatedNPCTabItems.value.filter(item => item.type === 'trade')
|
||||
})
|
||||
|
||||
const sortedIntelReports = computed(() => {
|
||||
return paginatedNPCTabItems.value.filter(item => item.type === 'intel')
|
||||
})
|
||||
|
||||
const sortedJointAttackInvites = computed(() => {
|
||||
return paginatedNPCTabItems.value.filter(item => item.type === 'jointAttack')
|
||||
})
|
||||
|
||||
// 未读战斗报告数量
|
||||
const unreadBattles = computed(() => {
|
||||
return gameStore.player.battleReports.filter(r => !r.read).length
|
||||
@@ -686,14 +908,38 @@
|
||||
return gameStore.player.giftRejectedNotifications.filter(n => !n.read).length
|
||||
})
|
||||
|
||||
// 待处理贸易提议数量(未过期)
|
||||
const pendingTradeOffers = computed(() => {
|
||||
const now = Date.now()
|
||||
return (gameStore.player.tradeOffers || []).filter(o => o.expiresAt > now).length
|
||||
})
|
||||
|
||||
// 未读情报报告数量
|
||||
const unreadIntelReports = computed(() => {
|
||||
return (gameStore.player.intelReports || []).filter(r => !r.read).length
|
||||
})
|
||||
|
||||
// 待处理联合攻击邀请数量(未过期)
|
||||
const pendingJointAttackInvites = computed(() => {
|
||||
const now = Date.now()
|
||||
return (gameStore.player.jointAttackInvites || []).filter(i => i.expiresAt > now).length
|
||||
})
|
||||
|
||||
// 合并:侦查相关未读总数(侦查报告 + 被侦查通知)
|
||||
const unreadSpyTotal = computed(() => {
|
||||
return unreadSpyReports.value + unreadSpiedNotifications.value
|
||||
})
|
||||
|
||||
// 合并:NPC相关未读总数(NPC活动 + 礼物通知 + 礼物被拒绝)
|
||||
// 合并:NPC相关未读总数(NPC活动 + 礼物通知 + 礼物被拒绝 + 贸易提议 + 情报 + 联合攻击邀请)
|
||||
const unreadNPCTotal = computed(() => {
|
||||
return unreadNPCActivity.value + unreadGiftNotifications.value + unreadGiftRejected.value
|
||||
return (
|
||||
unreadNPCActivity.value +
|
||||
unreadGiftNotifications.value +
|
||||
unreadGiftRejected.value +
|
||||
pendingTradeOffers.value +
|
||||
unreadIntelReports.value +
|
||||
pendingJointAttackInvites.value
|
||||
)
|
||||
})
|
||||
|
||||
// 标签页配置
|
||||
@@ -959,4 +1205,185 @@
|
||||
gameStore.player.giftRejectedNotifications.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 贸易提议相关 ==========
|
||||
|
||||
// 通过 NPC ID 获取名称
|
||||
const getNpcNameById = (npcId: string): string => {
|
||||
const npc = npcStore.npcs.find(n => n.id === npcId)
|
||||
return npc?.name || npcId
|
||||
}
|
||||
|
||||
// 检查贸易提议是否过期
|
||||
const isOfferExpired = (offer: TradeOffer): boolean => {
|
||||
const now = Date.now()
|
||||
return offer.expiresAt <= now
|
||||
}
|
||||
|
||||
// 检查联合攻击邀请是否过期
|
||||
const isInviteExpired = (invite: JointAttackInvite): boolean => {
|
||||
const now = Date.now()
|
||||
return invite.expiresAt <= now
|
||||
}
|
||||
|
||||
// 辅助函数:从资源对象中提取资源信息(兼容新旧格式)
|
||||
// 用于模板显示和逻辑处理
|
||||
const getResourceInfo = (resource: any): { type: 'metal' | 'crystal' | 'deuterium'; amount: number } | null => {
|
||||
if (!resource) return null
|
||||
|
||||
// 新格式:{ type: 'metal', amount: 1000 }
|
||||
if (resource.type && typeof resource.amount === 'number' && !isNaN(resource.amount)) {
|
||||
return { type: resource.type, amount: resource.amount }
|
||||
}
|
||||
|
||||
// 旧格式:{ metal: 1000, crystal: 0, deuterium: 0 }
|
||||
if (typeof resource.metal === 'number' && resource.metal > 0) {
|
||||
return { type: 'metal', amount: resource.metal }
|
||||
}
|
||||
if (typeof resource.crystal === 'number' && resource.crystal > 0) {
|
||||
return { type: 'crystal', amount: resource.crystal }
|
||||
}
|
||||
if (typeof resource.deuterium === 'number' && resource.deuterium > 0) {
|
||||
return { type: 'deuterium', amount: resource.deuterium }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 别名,供内部逻辑使用
|
||||
const extractResourceInfo = getResourceInfo
|
||||
|
||||
// 检查是否可以接受贸易
|
||||
const canAcceptTrade = (offer: TradeOffer): boolean => {
|
||||
const planet = gameStore.player.planets[0]
|
||||
if (!planet) return false
|
||||
|
||||
const requested = extractResourceInfo(offer.requestedResources)
|
||||
if (!requested) return false
|
||||
|
||||
return planet.resources[requested.type] >= requested.amount
|
||||
}
|
||||
|
||||
// 打开贸易提议详情对话框(目前直接操作,后续可添加对话框)
|
||||
const openTradeOfferDialog = (_offer: TradeOffer) => {
|
||||
// 目前贸易提议直接在卡片上操作,不需要单独的对话框
|
||||
}
|
||||
|
||||
// 接受贸易提议
|
||||
const acceptTradeOffer = (offer: TradeOffer) => {
|
||||
if (isOfferExpired(offer)) {
|
||||
toast.error(t('npcBehavior.trade.expired'))
|
||||
return
|
||||
}
|
||||
if (!canAcceptTrade(offer)) {
|
||||
toast.error(t('npcBehavior.trade.acceptFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const planet = gameStore.player.planets[0]
|
||||
if (!planet) return
|
||||
|
||||
const requested = extractResourceInfo(offer.requestedResources)
|
||||
const offered = extractResourceInfo(offer.offeredResources)
|
||||
|
||||
if (!requested || !offered) {
|
||||
toast.error(t('npcBehavior.trade.acceptFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 扣除请求的资源
|
||||
planet.resources[requested.type] -= requested.amount
|
||||
|
||||
// 添加获得的资源
|
||||
planet.resources[offered.type] += offered.amount
|
||||
|
||||
// 移除贸易提议
|
||||
deleteTradeOffer(offer.id)
|
||||
|
||||
// 提高与该NPC的好感度(使用 npcStore)
|
||||
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) => {
|
||||
deleteTradeOffer(offer.id)
|
||||
toast.info(t('npcBehavior.trade.declined'))
|
||||
}
|
||||
|
||||
// 删除贸易提议
|
||||
const deleteTradeOffer = (offerId: string) => {
|
||||
if (!gameStore.player.tradeOffers) return
|
||||
const index = gameStore.player.tradeOffers.findIndex(o => o.id === offerId)
|
||||
if (index > -1) {
|
||||
gameStore.player.tradeOffers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 情报报告相关 ==========
|
||||
|
||||
// 打开情报报告详情对话框
|
||||
const openIntelReportDialog = (intel: IntelReport) => {
|
||||
// 标记为已读
|
||||
const originalIntel = gameStore.player.intelReports?.find(i => i.id === intel.id)
|
||||
if (originalIntel && !originalIntel.read) {
|
||||
originalIntel.read = true
|
||||
}
|
||||
// 目前情报报告直接显示在卡片上,后续可添加详情对话框
|
||||
}
|
||||
|
||||
// 删除情报报告
|
||||
const deleteIntelReport = (intelId: string) => {
|
||||
if (!gameStore.player.intelReports) return
|
||||
const index = gameStore.player.intelReports.findIndex(i => i.id === intelId)
|
||||
if (index > -1) {
|
||||
gameStore.player.intelReports.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 联合攻击邀请相关 ==========
|
||||
|
||||
// 打开联合攻击邀请详情对话框
|
||||
const openJointAttackDialog = (_invite: JointAttackInvite) => {
|
||||
// 目前联合攻击邀请直接在卡片上操作,后续可添加详情对话框
|
||||
}
|
||||
|
||||
// 接受联合攻击邀请
|
||||
const acceptJointAttack = (invite: JointAttackInvite) => {
|
||||
if (isInviteExpired(invite)) {
|
||||
toast.error(t('npcBehavior.jointAttack.expired'))
|
||||
return
|
||||
}
|
||||
|
||||
// 移除邀请
|
||||
deleteJointAttackInvite(invite.id)
|
||||
|
||||
// 提高与该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) => {
|
||||
deleteJointAttackInvite(invite.id)
|
||||
toast.info(t('npcBehavior.jointAttack.declined'))
|
||||
}
|
||||
|
||||
// 删除联合攻击邀请
|
||||
const deleteJointAttackInvite = (inviteId: string) => {
|
||||
if (!gameStore.player.jointAttackInvites) return
|
||||
const index = gameStore.player.jointAttackInvites.findIndex(i => i.id === inviteId)
|
||||
if (index > -1) {
|
||||
gameStore.player.jointAttackInvites.splice(index, 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
const alertDialogRequirements = ref<Array<{ name: string; requiredLevel: number; currentLevel: number; met: boolean }>>([])
|
||||
const alertDialogShowRequirements = ref(false)
|
||||
|
||||
// 防抖状态:防止快速点击
|
||||
const isProcessing = ref(false)
|
||||
const DEBOUNCE_DELAY = 300 // 防抖延迟(毫秒)
|
||||
|
||||
// 资源类型配置(用于成本显示)
|
||||
const costResourceTypes = [
|
||||
{ key: 'metal' as const },
|
||||
@@ -252,6 +256,13 @@
|
||||
|
||||
// 研究科技
|
||||
const handleResearch = (techType: TechnologyType, event: MouseEvent) => {
|
||||
// 防抖:防止快速点击
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
setTimeout(() => {
|
||||
isProcessing.value = false
|
||||
}, DEBOUNCE_DELAY)
|
||||
|
||||
// 检查前置条件
|
||||
if (!checkUpgradeRequirements(techType)) {
|
||||
alertDialogTitle.value = t('common.requirementsNotMet')
|
||||
|
||||
@@ -38,6 +38,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebDAV 云同步 -->
|
||||
<div class="flex flex-col gap-3 p-4 border rounded-lg border-blue-500/30 bg-blue-500/5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-medium flex items-center gap-2">
|
||||
<Cloud class="h-4 w-4 text-blue-500" />
|
||||
{{ t('settings.webdav.title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">{{ t('settings.webdav.desc') }}</p>
|
||||
</div>
|
||||
<Button @click="showWebDAVConfig = true" variant="outline" size="sm">
|
||||
<Settings2 class="mr-2 h-4 w-4" />
|
||||
{{ t('settings.webdav.config') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="webdavConfig" class="flex gap-2 pt-2 border-t">
|
||||
<Button @click="handleWebDAVUpload" :disabled="isWebDAVUploading" class="flex-1" variant="outline">
|
||||
<CloudUpload class="mr-2 h-4 w-4" />
|
||||
{{ isWebDAVUploading ? t('settings.webdav.uploading') : t('settings.webdav.upload') }}
|
||||
</Button>
|
||||
<Button @click="showWebDAVFiles = true" class="flex-1" variant="outline">
|
||||
<CloudDownload class="mr-2 h-4 w-4" />
|
||||
{{ t('settings.webdav.download') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted-foreground">{{ t('settings.webdav.notConfigured') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 清除数据 -->
|
||||
<div class="flex items-center justify-between p-4 border rounded-lg border-destructive/50">
|
||||
<div class="space-y-1">
|
||||
@@ -97,7 +125,7 @@
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<!-- 浏览器通知 -->
|
||||
<div class="flex flex-col gap-4 p-4 border rounded-lg">
|
||||
<div class="flex flex-col gap-4 p-4 border rounded-lg" v-if="!Capacitor.isNativePlatform()">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<h3 class="font-medium">{{ t('settings.browserNotifications') }}</h3>
|
||||
@@ -304,6 +332,12 @@
|
||||
|
||||
<!-- 隐私协议弹窗 -->
|
||||
<PrivacyDialog v-model:open="showPrivacyDialog" />
|
||||
|
||||
<!-- WebDAV 配置对话框 -->
|
||||
<WebDAVConfigDialog v-model:open="showWebDAVConfig" @saved="onWebDAVConfigSaved" />
|
||||
|
||||
<!-- WebDAV 文件列表对话框 -->
|
||||
<WebDAVFileListDialog v-model:open="showWebDAVFiles" :config="webdavConfig" @select="handleWebDAVDownload" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -337,7 +371,11 @@
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
RotateCcw,
|
||||
Shield
|
||||
Shield,
|
||||
Cloud,
|
||||
CloudUpload,
|
||||
CloudDownload,
|
||||
Settings2
|
||||
} from 'lucide-vue-next'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -348,7 +386,15 @@
|
||||
import type { VersionInfo } from '@/utils/versionCheck'
|
||||
import UpdateDialog from '@/components/dialogs/UpdateDialog.vue'
|
||||
import PrivacyDialog from '@/components/dialogs/PrivacyDialog.vue'
|
||||
import WebDAVConfigDialog from '@/components/settings/WebDAVConfigDialog.vue'
|
||||
import WebDAVFileListDialog from '@/components/settings/WebDAVFileListDialog.vue'
|
||||
import { useHints } from '@/composables/useHints'
|
||||
import {
|
||||
type WebDAVConfig,
|
||||
getWebDAVConfig,
|
||||
uploadToWebDAV,
|
||||
downloadFromWebDAV
|
||||
} from '@/services/webdavService'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { hintsEnabled, setHintsEnabled, resetHints } = useHints()
|
||||
@@ -366,6 +412,12 @@
|
||||
|
||||
const isTypesExpanded = ref(false)
|
||||
|
||||
// WebDAV 相关状态
|
||||
const showWebDAVConfig = ref(false)
|
||||
const showWebDAVFiles = ref(false)
|
||||
const webdavConfig = ref<WebDAVConfig | null>(getWebDAVConfig())
|
||||
const isWebDAVUploading = ref(false)
|
||||
|
||||
// 确保通知设置存在
|
||||
if (!gameStore.notificationSettings) {
|
||||
gameStore.notificationSettings = {
|
||||
@@ -705,4 +757,98 @@
|
||||
const updateBackgroundSetting = (val: boolean) => {
|
||||
gameStore.player.backgroundEnabled = val
|
||||
}
|
||||
|
||||
// WebDAV 配置保存回调
|
||||
const onWebDAVConfigSaved = () => {
|
||||
webdavConfig.value = getWebDAVConfig()
|
||||
}
|
||||
|
||||
// WebDAV 上传
|
||||
const handleWebDAVUpload = async () => {
|
||||
if (!webdavConfig.value) return
|
||||
|
||||
isWebDAVUploading.value = true
|
||||
try {
|
||||
// 获取游戏数据
|
||||
const gameData = localStorage.getItem(pkg.name)
|
||||
const universeData = localStorage.getItem(`${pkg.name}-universe`)
|
||||
const npcData = localStorage.getItem(`${pkg.name}-npcs`)
|
||||
|
||||
if (!gameData) {
|
||||
toast.error(t('settings.exportFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 合并数据
|
||||
const exportData = {
|
||||
game: gameData,
|
||||
npcs: npcData,
|
||||
universe: universeData || null
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(exportData, null, 2)
|
||||
const result = await uploadToWebDAV(webdavConfig.value, jsonString)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(t('settings.webdav.uploadSuccess'))
|
||||
} else {
|
||||
toast.error(result.message || t('settings.webdav.uploadFailed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebDAV upload failed:', error)
|
||||
toast.error(t('settings.webdav.uploadFailed'))
|
||||
} finally {
|
||||
isWebDAVUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// WebDAV 下载
|
||||
const handleWebDAVDownload = async (fileName: string) => {
|
||||
if (!webdavConfig.value) return
|
||||
|
||||
try {
|
||||
const result = await downloadFromWebDAV(webdavConfig.value, fileName)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
toast.error(result.message || t('settings.webdav.downloadFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
// 确认导入
|
||||
confirmTitle.value = t('settings.importConfirmTitle')
|
||||
confirmMessage.value = t('settings.importConfirmMessage')
|
||||
showConfirmDialog.value = true
|
||||
gameStore.isPaused = true
|
||||
|
||||
confirmCallback = () => {
|
||||
try {
|
||||
const importData = JSON.parse(result.data!)
|
||||
|
||||
// 兼容旧版本格式
|
||||
if (typeof importData === 'string' || !importData.game) {
|
||||
localStorage.setItem(pkg.name, result.data!)
|
||||
} else {
|
||||
if (importData.game) {
|
||||
localStorage.setItem(pkg.name, importData.game)
|
||||
}
|
||||
if (importData.universe) {
|
||||
localStorage.setItem(`${pkg.name}-universe`, importData.universe)
|
||||
}
|
||||
if (importData.npcs) {
|
||||
localStorage.setItem(`${pkg.name}-npcs`, importData.npcs)
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t('settings.importSuccess'))
|
||||
setTimeout(() => window.location.reload(), 1000)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
toast.error(t('settings.importFailed') + ': ' + message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebDAV download failed:', error)
|
||||
toast.error(t('settings.webdav.downloadFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -200,6 +200,10 @@
|
||||
const alertDialogTitle = ref('')
|
||||
const alertDialogMessage = ref('')
|
||||
|
||||
// 防抖状态:防止快速点击
|
||||
const isProcessing = ref(false)
|
||||
const DEBOUNCE_DELAY = 300 // 防抖延迟(毫秒)
|
||||
|
||||
// 资源类型配置(用于成本显示)
|
||||
const costResourceTypes = [
|
||||
{ key: 'metal' as const },
|
||||
@@ -255,6 +259,13 @@
|
||||
|
||||
// 建造舰船
|
||||
const handleBuild = (shipType: ShipType, event: MouseEvent) => {
|
||||
// 防抖:防止快速点击
|
||||
if (isProcessing.value) return
|
||||
isProcessing.value = true
|
||||
setTimeout(() => {
|
||||
isProcessing.value = false
|
||||
}, DEBOUNCE_DELAY)
|
||||
|
||||
const quantity = quantities.value[shipType]
|
||||
if (quantity <= 0) {
|
||||
alertDialogTitle.value = t('shipyardView.inputError')
|
||||
|
||||
Reference in New Issue
Block a user