docs: 新增西班牙语和日语README并优化多语言文档

新增README-ES.md(西班牙语)和README-JA.md(日语)文档,完善多语言README互链。优化各语言README徽章、技术栈、外链格式及语言切换区,提升文档一致性与可读性。
This commit is contained in:
谦君
2025-12-25 18:25:08 +08:00
parent b24a262ca7
commit 724a70bebb
72 changed files with 13300 additions and 2133 deletions

View File

@@ -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

View File

@@ -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>

View File

@@ -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')

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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')

View File

@@ -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>

View File

@@ -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')