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

@@ -55,8 +55,8 @@
class="transition-all duration-300"
/>
<!-- 流动动画点激活状态 -->
<circle v-if="connection.isActive" r="3" :fill="'hsl(var(--primary))'" class="animate-flow">
<animateMotion :dur="'2s'" repeatCount="indefinite" :path="connection.path" />
<circle v-if="connection.isActive" r="3" fill="#CDD1D7" class="animate-flow">
<animateMotion dur="2s" repeatCount="indefinite" :path="connection.path" />
</circle>
</template>
</g>
@@ -87,7 +87,7 @@
<!-- 图例 -->
<div class="absolute top-4 left-4 flex flex-wrap gap-3 text-xs">
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full bg-green-500" />
<div class="w-3 h-3 rounded-full bg-green-500 dark:bg-green-400" />
<span class="text-muted-foreground">{{ t('campaign.completed') }}</span>
</div>
<div class="flex items-center gap-1">
@@ -95,7 +95,7 @@
<span class="text-muted-foreground">{{ t('campaign.inProgress') }}</span>
</div>
<div class="flex items-center gap-1">
<div class="w-3 h-3 rounded-full bg-blue-400 animate-pulse" />
<div class="w-3 h-3 rounded-full bg-blue-400 dark:bg-blue-300 animate-pulse" />
<span class="text-muted-foreground">{{ t('campaign.available') }}</span>
</div>
<div class="flex items-center gap-1">

View File

@@ -18,7 +18,7 @@
</div>
<!-- 分支标记 -->
<div v-if="quest.isBranch" class="absolute -top-1 -left-1 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<div v-if="quest.isBranch" class="absolute -top-1 -left-1 w-5 h-5 bg-blue-500 dark:bg-blue-400 rounded-full flex items-center justify-center">
<GitBranch class="w-3 h-3 text-white" />
</div>
@@ -50,7 +50,7 @@
</div>
<!-- 脉冲动画可用状态 -->
<div v-if="status === QuestStatus.Available" class="absolute inset-0 w-14 h-14 rounded-full animate-ping bg-blue-400/30" />
<div v-if="status === QuestStatus.Available" class="absolute inset-0 w-14 h-14 rounded-full animate-ping bg-blue-400/30 dark:bg-blue-300/30" />
</div>
</template>

View File

@@ -2,7 +2,7 @@
<!-- 遮罩从标题下方开始不遮挡名称 -->
<div
v-if="!isUnlocked"
class="absolute inset-x-0 top-30 sm:top-25 bottom-0 z-10 bg-background/70 backdrop-blur-[2px] rounded-b-lg flex items-center justify-center"
class="absolute inset-x-0 top-20 sm:top-13 bottom-0 z-10 bg-background/70 backdrop-blur-[2px] rounded-b-lg flex items-center justify-center"
>
<div class="text-center p-4 space-y-2">
<div class="flex justify-center">

View File

@@ -437,7 +437,7 @@
import { ref, computed } from 'vue'
import { useI18n } from '@/composables/useI18n'
import { useGameStore } from '@/stores/gameStore'
import type { BuildingType, TechnologyType, ShipType, DefenseType } from '@/types/game'
import { BuildingType, TechnologyType, type ShipType, type DefenseType } from '@/types/game'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
@@ -452,7 +452,7 @@
import * as shipLogic from '@/logic/shipLogic'
import * as oreDepositLogic from '@/logic/oreDepositLogic'
import * as resourceLogic from '@/logic/resourceLogic'
import { SHIPS, DEFENSES } from '@/config/gameConfig'
import { SHIPS, DEFENSES, ORE_DEPOSIT_CONFIG } from '@/config/gameConfig'
import { formatTime } from '@/utils/format'
import { Progress } from '@/components/ui/progress'
import ResourceIcon from '@/components/common/ResourceIcon.vue'
@@ -576,11 +576,23 @@
const deposits = currentPlanet.value.oreDeposits
const resourceType = miningResourceType.value
const remaining = deposits[resourceType]
const initial =
resourceType === 'metal' ? deposits.initialMetal : resourceType === 'crystal' ? deposits.initialCrystal : deposits.initialDeuterium
const percentage = oreDepositLogic.getDepositPercentage(deposits, resourceType)
const isWarning = oreDepositLogic.isDepositWarning(deposits, resourceType)
const isDepleted = oreDepositLogic.isDepositDepleted(deposits, resourceType)
// 获取深层钻探设施等级(星球级)和采矿技术等级(全局)
const deepDrillingLevel = currentPlanet.value.buildings[BuildingType.DeepDrillingFacility] || 0
const miningTechLevel = gameStore.player?.technologies?.[TechnologyType.MiningTechnology] || 0
// 使用增强版计算函数获取带加成的储量上限
const enhancedDeposits = oreDepositLogic.calculateEnhancedDeposits(
deposits.position,
deepDrillingLevel,
miningTechLevel
)
const initial = enhancedDeposits[resourceType]
// 百分比基于增强后的上限计算
const percentage = initial > 0 ? (remaining / initial) * 100 : 0
const isWarning = percentage < ORE_DEPOSIT_CONFIG.WARNING_THRESHOLD * 100 && percentage > 0
const isDepleted = remaining <= 0
// 计算当前产量(每小时)
const production = resourceLogic.calculateResourceProduction(currentPlanet.value, {
@@ -591,7 +603,7 @@
const productionPerHour = production[resourceType]
// 计算耗尽时间
const depletionTimeHours = oreDepositLogic.calculateDepletionTime(deposits, resourceType, productionPerHour)
const depletionTimeHours = productionPerHour > 0 ? remaining / productionPerHour : Infinity
const depletionTimeFormatted = oreDepositLogic.formatDepletionTime(depletionTimeHours)
return {
@@ -601,7 +613,10 @@
isWarning,
isDepleted,
productionPerHour,
depletionTimeFormatted
depletionTimeFormatted,
bonusMultiplier: enhancedDeposits.bonusMultiplier,
deepDrillingLevel,
miningTechLevel
}
})

View File

@@ -1,7 +1,9 @@
<template>
<Popover>
<PopoverTrigger as-child>
<span class="cursor-pointer underline decoration-dotted underline-offset-4 touch-manipulation">{{ formatNumber(value, 1) }}</span>
<span class="cursor-pointer touch-manipulation" :class="value >= 1000 ? 'underline decoration-dotted underline-offset-4 ' : ''">
{{ formatNumber(value, 1) }}
</span>
</PopoverTrigger>
<PopoverContent class="w-auto p-2 z-100" side="top" align="center">
<p class="font-mono text-sm">{{ props.value.toLocaleString() }}</p>

View File

@@ -20,7 +20,7 @@
</Button>
</div>
<ScrollArea class="h-96">
<Empty v-if="reports.length === 0" class="border-0">
<Empty v-if="allNotifications.length === 0" class="border-0">
<EmptyContent>
<ScrollText class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('diplomacy.noReports') }}</EmptyDescription>
@@ -28,48 +28,49 @@
</Empty>
<div v-else class="divide-y">
<div
v-for="report in reports"
:key="report.id"
v-for="notification in allNotifications"
:key="notification.id"
class="p-3 hover:bg-muted/50 cursor-pointer transition-colors"
:class="{ 'bg-primary/5': !report.read }"
@click="handleReportClick(report)"
:class="{ 'bg-primary/5': !notification.read }"
@click="handleNotificationClick(notification)"
>
<div class="flex items-center gap-3">
<!-- 左侧事件图标 -->
<div class="shrink-0">
<component :is="getEventIcon(report.eventType)" class="h-5 w-5" :class="getEventIconColor(report.eventType)" />
<component :is="getNotificationIcon(notification)" class="h-5 w-5" :class="getNotificationIconColor(notification)" />
</div>
<!-- 中间主要信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ getNpcName(report) }}</span>
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs shrink-0">
{{ getStatusText(report.newStatus) }}
<span class="font-medium text-sm truncate">{{ getNotificationNpcName(notification) }}</span>
<Badge :variant="getNotificationBadgeVariant(notification)" class="text-xs shrink-0">
{{ getNotificationBadgeText(notification) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-0.5">
{{ getEventTypeText(report.eventType) }}
{{ getNotificationTitle(notification) }}
</p>
</div>
<!-- 右侧好感度变化和时间 -->
<!-- 右侧额外信息和时间 -->
<div class="shrink-0 text-right">
<span
v-if="getNotificationExtra(notification)"
class="text-sm font-bold block"
:class="report.reputationChange >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
:class="getNotificationExtra(notification)?.colorClass"
>
{{ report.reputationChange >= 0 ? '+' : '' }}{{ report.reputationChange }}
{{ getNotificationExtra(notification)?.text }}
</span>
<span class="text-[10px] text-muted-foreground">
{{ formatRelativeTime((Date.now() - report.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
{{ formatRelativeTime((Date.now() - notification.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</span>
</div>
<!-- 未读标记 -->
<span v-if="!report.read" class="h-2 w-2 rounded-full bg-destructive shrink-0" />
<span v-if="!notification.read" class="h-2 w-2 rounded-full bg-destructive shrink-0" />
</div>
</div>
</div>
</ScrollArea>
<div v-if="reports.length > 0" class="p-2 border-t">
<div v-if="allNotifications.length > 0" class="p-2 border-t">
<Button variant="ghost" size="sm" class="w-full" @click="goToDiplomacy">
{{ t('diplomacy.viewAll') }}
</Button>
@@ -78,7 +79,7 @@
</Popover>
<!-- 外交报告详情对话框 -->
<Dialog :open="detailDialogOpen" @update:open="detailDialogOpen = $event">
<Dialog :open="detailDialogOpen && selectedNotification?.type === 'diplomatic'" @update:open="detailDialogOpen = $event">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
@@ -101,7 +102,7 @@
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ getNpcName(selectedReport) }}</h3>
<Badge :variant="getStatusBadgeVariant(selectedReport.newStatus)">
<Badge v-if="selectedReport.newStatus" :variant="getStatusBadgeVariant(selectedReport.newStatus)">
{{ getStatusText(selectedReport.newStatus) }}
</Badge>
</div>
@@ -123,36 +124,41 @@
</p>
</div>
<!-- 关系变化 -->
<div class="grid grid-cols-2 gap-4">
<!-- 关系变化 (仅当有 reputationChange 时显示) -->
<div v-if="selectedReport.reputationChange !== undefined" class="grid grid-cols-2 gap-4">
<!-- 好感度变化 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('diplomacy.reputationChange') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<div class="flex items-center justify-between text-sm mb-2">
<span class="text-muted-foreground">{{ t('diplomacy.before') }}</span>
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation - selectedReport.reputationChange)">
{{ selectedReport.newReputation - selectedReport.reputationChange > 0 ? '+' : ''
}}{{ selectedReport.newReputation - selectedReport.reputationChange }}
<span
class="font-semibold"
:class="getReputationColor((selectedReport.newReputation || 0) - (selectedReport.reputationChange || 0))"
>
{{ (selectedReport.newReputation || 0) - (selectedReport.reputationChange || 0) > 0 ? '+' : ''
}}{{ (selectedReport.newReputation || 0) - (selectedReport.reputationChange || 0) }}
</span>
</div>
<div
class="flex items-center justify-center text-lg font-bold my-1"
:class="selectedReport.reputationChange >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
:class="
(selectedReport.reputationChange || 0) >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
"
>
{{ selectedReport.reputationChange >= 0 ? '+' : '' }}{{ selectedReport.reputationChange }}
{{ (selectedReport.reputationChange || 0) >= 0 ? '+' : '' }}{{ selectedReport.reputationChange || 0 }}
</div>
<div class="flex items-center justify-between text-sm mt-2">
<span class="text-muted-foreground">{{ t('diplomacy.after') }}</span>
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation)">
{{ selectedReport.newReputation > 0 ? '+' : '' }}{{ selectedReport.newReputation }}
<span class="font-semibold" :class="getReputationColor(selectedReport.newReputation ?? null)">
{{ (selectedReport.newReputation || 0) > 0 ? '+' : '' }}{{ selectedReport.newReputation || 0 }}
</span>
</div>
</div>
</div>
<!-- 关系状态变化 -->
<div class="space-y-2">
<div v-if="selectedReport.oldStatus && selectedReport.newStatus" class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('diplomacy.statusChange') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<div class="flex items-center justify-between text-sm mb-2">
@@ -189,10 +195,294 @@
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 贸易提议详情对话框 -->
<Dialog :open="detailDialogOpen && selectedNotification?.type === 'trade'" @update:open="detailDialogOpen = $event">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ArrowLeftRight class="h-5 w-5 text-blue-500" />
{{ t('npcBehavior.trade.title') }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ t('npcBehavior.trade.title') }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedTradeOffer" class="space-y-4">
<!-- NPC信息卡片 -->
<div class="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ getTradeNpcName(selectedTradeOffer) }}</h3>
<Badge variant="outline" class="bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30">
{{ t('diplomacy.notificationBadge.trade') }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">
{{ formatRelativeTime((Date.now() - selectedTradeOffer.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</p>
</div>
</div>
<!-- 交易内容 -->
<div class="grid grid-cols-2 gap-4">
<!-- 对方提供 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm flex items-center gap-1.5">
<span class="h-2 w-2 rounded-full bg-green-500 dark:bg-green-400"></span>
{{ t('npcBehavior.trade.offers') }}
</h4>
<div class="p-3 bg-green-500/10 rounded-md">
<div class="flex items-center gap-2">
<ResourceIcon :type="selectedTradeOffer.offeredResources.type" size="md" />
<div>
<div class="text-lg font-bold text-green-600 dark:text-green-400">
<NumberWithTooltip :value="selectedTradeOffer.offeredResources.amount" />
</div>
<div class="text-xs text-muted-foreground">{{ t(`resources.${selectedTradeOffer.offeredResources.type}`) }}</div>
</div>
</div>
</div>
</div>
<!-- 对方要求 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm flex items-center gap-1.5">
<span class="h-2 w-2 rounded-full bg-orange-500 dark:bg-orange-400"></span>
{{ t('npcBehavior.trade.requests') }}
</h4>
<div class="p-3 bg-orange-500/10 rounded-md">
<div class="flex items-center gap-2">
<ResourceIcon :type="selectedTradeOffer.requestedResources.type" size="md" />
<div>
<div class="text-lg font-bold text-orange-600 dark:text-orange-400">
<NumberWithTooltip :value="selectedTradeOffer.requestedResources.amount" />
</div>
<div class="text-xs text-muted-foreground">{{ t(`resources.${selectedTradeOffer.requestedResources.type}`) }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 交易比率和过期时间 -->
<div class="p-3 bg-muted/30 rounded-md space-y-2">
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('npcBehavior.trade.ratio') }}</span>
<span class="font-medium">{{ getTradeRatioText(selectedTradeOffer) }}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('npcBehavior.trade.expiresIn') }}</span>
<span :class="getTradeExpiresClass(selectedTradeOffer)">{{ getTradeExpiresText(selectedTradeOffer) }}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
<Button @click="goToMessagesFromDialog">{{ t('diplomacy.viewAll') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 情报报告详情对话框 -->
<Dialog :open="detailDialogOpen && selectedNotification?.type === 'intel'" @update:open="detailDialogOpen = $event">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<FileSearch class="h-5 w-5 text-cyan-500" />
{{ t('npcBehavior.intel.title') }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ t('npcBehavior.intel.title') }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedIntelReport" class="space-y-4">
<!-- 来源NPC信息 -->
<div class="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ getIntelFromNpcName(selectedIntelReport) }}</h3>
<Badge variant="secondary" class="bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/30">
{{ t('diplomacy.notificationBadge.intel') }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">
{{ formatRelativeTime((Date.now() - selectedIntelReport.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</p>
</div>
</div>
<!-- 情报详情 -->
<div class="grid grid-cols-2 gap-4">
<!-- 目标 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('npcBehavior.intel.target') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<div class="font-medium">{{ getIntelTargetNpcName(selectedIntelReport) }}</div>
</div>
</div>
<!-- 情报类型 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('npcBehavior.intel.type') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<div class="flex items-center gap-2">
<component
:is="getIntelTypeIcon(selectedIntelReport)"
class="h-4 w-4"
:class="getIntelTypeIconColor(selectedIntelReport)"
/>
<span class="font-medium">{{ getIntelTypeText(selectedIntelReport) }}</span>
</div>
</div>
</div>
</div>
<!-- 情报内容 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('npcBehavior.intel.content') }}</h4>
<div class="p-4 bg-cyan-500/5 border border-cyan-500/20 rounded-md">
<!-- 舰队情报 -->
<div v-if="selectedIntelReport.intelType === 'enemyFleet' && selectedIntelReport.data.fleet" class="space-y-2">
<div v-for="(count, ship) in selectedIntelReport.data.fleet" :key="ship" class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t(`ships.${ship}`) }}</span>
<span class="font-medium">{{ (count as number).toLocaleString() }}</span>
</div>
<div v-if="Object.keys(selectedIntelReport.data.fleet).length === 0" class="text-sm text-muted-foreground text-center">
{{ t('npcBehavior.intel.noFleet') }}
</div>
</div>
<!-- 资源情报 -->
<div v-else-if="selectedIntelReport.intelType === 'enemyResources' && selectedIntelReport.data.resources" class="space-y-2">
<div v-if="selectedIntelReport.data.resources.metal" class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('resources.metal') }}</span>
<span class="font-medium text-gray-500">{{ selectedIntelReport.data.resources.metal.toLocaleString() }}</span>
</div>
<div v-if="selectedIntelReport.data.resources.crystal" class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('resources.crystal') }}</span>
<span class="font-medium text-cyan-500">{{ selectedIntelReport.data.resources.crystal.toLocaleString() }}</span>
</div>
<div v-if="selectedIntelReport.data.resources.deuterium" class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('resources.deuterium') }}</span>
<span class="font-medium text-green-500">{{ selectedIntelReport.data.resources.deuterium.toLocaleString() }}</span>
</div>
</div>
<!-- 移动情报 -->
<div v-else-if="selectedIntelReport.intelType === 'enemyMovement' && selectedIntelReport.data.movement" class="space-y-2">
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('npcBehavior.intel.targetPosition') }}</span>
<span class="font-mono font-medium">
[{{ selectedIntelReport.data.movement.targetPosition.galaxy }}:{{
selectedIntelReport.data.movement.targetPosition.system
}}:{{ selectedIntelReport.data.movement.targetPosition.position }}]
</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">{{ t('npcBehavior.intel.missionType') }}</span>
<span class="font-medium">{{ selectedIntelReport.data.movement.missionType }}</span>
</div>
</div>
<!-- 默认 -->
<div v-else class="text-sm text-muted-foreground text-center">
{{ t('npcBehavior.intel.noData') }}
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
<Button @click="goToMessagesFromDialog">{{ t('diplomacy.viewAll') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 联合攻击邀请详情对话框 -->
<Dialog :open="detailDialogOpen && selectedNotification?.type === 'jointAttack'" @update:open="detailDialogOpen = $event">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<Users class="h-5 w-5 text-orange-500" />
{{ t('npcBehavior.jointAttack.title') }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ t('npcBehavior.jointAttack.title') }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedJointAttack" class="space-y-4">
<!-- 邀请方NPC信息 -->
<div class="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ getJointAttackFromNpcName(selectedJointAttack) }}</h3>
<Badge variant="destructive" class="bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30">
{{ t('diplomacy.notificationBadge.jointAttack') }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">
{{ formatRelativeTime((Date.now() - selectedJointAttack.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</p>
</div>
</div>
<!-- 攻击目标信息 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm flex items-center gap-1.5">
<Sword class="h-4 w-4 text-red-500" />
{{ t('npcBehavior.jointAttack.targetInfo') }}
</h4>
<div class="p-4 bg-red-500/5 border border-red-500/20 rounded-md space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">{{ t('npcBehavior.jointAttack.target') }}</span>
<span class="font-medium">{{ getJointAttackTargetNpcName(selectedJointAttack) }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">{{ t('npcBehavior.jointAttack.targetPlanet') }}</span>
<span class="font-mono font-medium">{{ getJointAttackTargetPlanet(selectedJointAttack) }}</span>
</div>
</div>
</div>
<!-- 收益和时限 -->
<div class="grid grid-cols-2 gap-4">
<!-- 战利品分成 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('npcBehavior.jointAttack.lootShare') }}</h4>
<div class="p-3 bg-orange-500/10 border border-orange-500/20 rounded-md text-center">
<div class="text-2xl font-bold text-orange-600 dark:text-orange-400">
{{ Math.round(selectedJointAttack.expectedLootRatio * 100) }}%
</div>
<div class="text-xs text-muted-foreground mt-1">{{ t('npcBehavior.jointAttack.expectedShare') }}</div>
</div>
</div>
<!-- 过期时间 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('npcBehavior.jointAttack.expiresIn') }}</h4>
<div class="p-3 bg-muted/30 rounded-md text-center">
<div class="text-2xl font-bold" :class="getJointAttackExpiresClass(selectedJointAttack)">
{{ getJointAttackExpiresText(selectedJointAttack) }}
</div>
<div class="text-xs text-muted-foreground mt-1">{{ t('npcBehavior.jointAttack.remaining') }}</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
<Button @click="goToMessagesFromDialog">{{ t('diplomacy.viewAll') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { useNPCStore } from '@/stores/npcStore'
@@ -202,11 +492,24 @@
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { ScrollText, Gift, Sword, Eye, Trash2, Skull } from 'lucide-vue-next'
import { ScrollText, Gift, Sword, Eye, Trash2, Skull, ArrowLeftRight, FileSearch, Users, Ship, Coins, Navigation } from 'lucide-vue-next'
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
import { RelationStatus, DiplomaticEventType } from '@/types/game'
import type { DiplomaticReport } from '@/types/game'
import type { DiplomaticReport, TradeOffer, IntelReport, JointAttackInvite } from '@/types/game'
import { formatRelativeTime } from '@/utils/format'
import NumberWithTooltip from '@/components/common/NumberWithTooltip.vue'
import ResourceIcon from '@/components/common/ResourceIcon.vue'
// 统一通知类型
type NotificationType = 'diplomatic' | 'trade' | 'intel' | 'jointAttack'
interface UnifiedNotification {
id: string
type: NotificationType
timestamp: number
read: boolean
data: DiplomaticReport | TradeOffer | IntelReport | JointAttackInvite
}
const router = useRouter()
const gameStore = useGameStore()
@@ -214,35 +517,195 @@
const { t } = useI18n()
const isOpen = ref(false)
const detailDialogOpen = ref(false)
const selectedReport = ref<DiplomaticReport | null>(null)
const selectedNotification = ref<UnifiedNotification | null>(null)
const reports = computed(() => {
return (gameStore.player.diplomaticReports || []).slice().reverse().slice(0, 20) // 最近20条
// 倒计时相关
const currentTime = ref(Date.now())
let countdownTimer: ReturnType<typeof setInterval> | null = null
// 启动倒计时定时器
const startCountdownTimer = () => {
if (countdownTimer) return
countdownTimer = setInterval(() => {
currentTime.value = Date.now()
}, 1000)
}
// 停止倒计时定时器
const stopCountdownTimer = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
// 监听对话框打开状态
watch(detailDialogOpen, open => {
if (open) {
startCountdownTimer()
} else {
stopCountdownTimer()
}
})
// 组件卸载时清理
onUnmounted(() => {
stopCountdownTimer()
})
// 格式化倒计时(毫秒 -> HH:MM:SS 或 MM:SS
const formatCountdown = (ms: number): string => {
if (ms <= 0) return '00:00'
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
// 选中的各类型数据(计算属性)
const selectedReport = computed(() => {
if (selectedNotification.value?.type === 'diplomatic') {
return selectedNotification.value.data as DiplomaticReport
}
return null
})
const selectedTradeOffer = computed(() => {
if (selectedNotification.value?.type === 'trade') {
return selectedNotification.value.data as TradeOffer
}
return null
})
const selectedIntelReport = computed(() => {
if (selectedNotification.value?.type === 'intel') {
return selectedNotification.value.data as IntelReport
}
return null
})
const selectedJointAttack = computed(() => {
if (selectedNotification.value?.type === 'jointAttack') {
return selectedNotification.value.data as JointAttackInvite
}
return null
})
// 统一所有通知到一个列表
const allNotifications = computed((): UnifiedNotification[] => {
const notifications: UnifiedNotification[] = []
// 外交报告
;(gameStore.player.diplomaticReports || []).forEach(report => {
notifications.push({
id: report.id,
type: 'diplomatic',
timestamp: report.timestamp,
read: report.read ?? false,
data: report
})
})
// 贸易提议只显示pending状态的
;(gameStore.player.tradeOffers || [])
.filter(offer => offer.status === 'pending')
.forEach(offer => {
notifications.push({
id: offer.id,
type: 'trade',
timestamp: offer.timestamp,
read: offer.read || false,
data: offer
})
})
// 情报报告
;(gameStore.player.intelReports || []).forEach(report => {
notifications.push({
id: report.id,
type: 'intel',
timestamp: report.timestamp,
read: report.read,
data: report
})
})
// 联合攻击邀请只显示pending状态的
;(gameStore.player.jointAttackInvites || [])
.filter(invite => invite.status === 'pending')
.forEach(invite => {
notifications.push({
id: invite.id,
type: 'jointAttack',
timestamp: invite.timestamp,
read: invite.read || false,
data: invite
})
})
// 按时间倒序排序取最近30条
return notifications.sort((a, b) => b.timestamp - a.timestamp).slice(0, 30)
})
const unreadCount = computed(() => {
return allNotifications.value.filter(n => !n.read).length
})
/**
* 获取NPC当前名称
* 优先使用当前NPC的实际名称如果NPC不存在则尝试从旧名称中提取ID查找
* 获取NPC当前名称(通用)
*/
const getNpcName = (report: DiplomaticReport): string => {
if (!npcStore.npcs?.length) return report.npcName
const getNpcNameById = (npcId: string, fallbackName: string): string => {
if (!npcStore.npcs?.length) return fallbackName
// 1. 先通过 npcId 查找
if (report.npcId) {
const npc = npcStore.npcs.find(n => n.id === report.npcId)
if (npc) return npc.name
}
const npc = npcStore.npcs.find(n => n.id === npcId)
if (npc) return npc.name
// 2. 尝试从旧名称中提取ID并查找
// 旧格式如 "NPC-npc_182"新ID格式为 "npc_182"
const idMatch = report.npcName.match(/npc_\d+/)
// 尝试从旧名称中提取ID并查找
const idMatch = fallbackName.match(/npc_\d+/)
if (idMatch) {
const extractedId = idMatch[0]
const npc = npcStore.npcs.find(n => n.id === extractedId)
if (npc) return npc.name
const foundNpc = npcStore.npcs.find(n => n.id === extractedId)
if (foundNpc) return foundNpc.name
}
return report.npcName
return fallbackName
}
/**
* 获取NPC当前名称外交报告专用
*/
const getNpcName = (report: DiplomaticReport): string => {
return getNpcNameById(report.npcId || '', report.npcName)
}
/**
* 获取通知的NPC名称
*/
const getNotificationNpcName = (notification: UnifiedNotification): string => {
switch (notification.type) {
case 'diplomatic': {
const report = notification.data as DiplomaticReport
return getNpcNameById(report.npcId || '', report.npcName)
}
case 'trade': {
const offer = notification.data as TradeOffer
return getNpcNameById(offer.npcId, offer.npcName)
}
case 'intel': {
const report = notification.data as IntelReport
return getNpcNameById(report.fromNpcId, report.fromNpcName)
}
case 'jointAttack': {
const invite = notification.data as JointAttackInvite
return getNpcNameById(invite.fromNpcId, invite.fromNpcName)
}
default:
return ''
}
}
/**
@@ -256,9 +719,145 @@
}
}
const unreadCount = computed(() => {
return (gameStore.player.diplomaticReports || []).filter(r => !r.read).length
})
/**
* 获取通知图标
*/
const getNotificationIcon = (notification: UnifiedNotification) => {
switch (notification.type) {
case 'trade':
return ArrowLeftRight
case 'intel':
return FileSearch
case 'jointAttack':
return Users
case 'diplomatic': {
const report = notification.data as DiplomaticReport
return getEventIcon(report.eventType)
}
default:
return ScrollText
}
}
/**
* 获取通知图标颜色
*/
const getNotificationIconColor = (notification: UnifiedNotification) => {
switch (notification.type) {
case 'trade':
return 'text-blue-500'
case 'intel':
return 'text-cyan-500'
case 'jointAttack':
return 'text-orange-500'
case 'diplomatic': {
const report = notification.data as DiplomaticReport
return getEventIconColor(report.eventType)
}
default:
return 'text-muted-foreground'
}
}
/**
* 获取通知标题
*/
const getNotificationTitle = (notification: UnifiedNotification): string => {
switch (notification.type) {
case 'trade':
return t('diplomacy.notificationType.tradeOffer')
case 'intel':
return t('diplomacy.notificationType.intelReport')
case 'jointAttack':
return t('diplomacy.notificationType.jointAttack')
case 'diplomatic': {
const report = notification.data as DiplomaticReport
return getEventTypeText(report.eventType)
}
default:
return ''
}
}
/**
* 获取通知徽章变体
*/
const getNotificationBadgeVariant = (notification: UnifiedNotification) => {
switch (notification.type) {
case 'trade':
return 'outline' as const
case 'intel':
return 'secondary' as const
case 'jointAttack':
return 'destructive' as const
case 'diplomatic': {
const report = notification.data as DiplomaticReport
return report.newStatus ? getStatusBadgeVariant(report.newStatus) : ('secondary' as const)
}
default:
return 'secondary' as const
}
}
/**
* 获取通知徽章文字
*/
const getNotificationBadgeText = (notification: UnifiedNotification): string => {
switch (notification.type) {
case 'trade':
return t('diplomacy.notificationBadge.trade')
case 'intel':
return t('diplomacy.notificationBadge.intel')
case 'jointAttack':
return t('diplomacy.notificationBadge.jointAttack')
case 'diplomatic': {
const report = notification.data as DiplomaticReport
return report.newStatus ? getStatusText(report.newStatus) : t('diplomacy.notificationBadge.diplomatic')
}
default:
return ''
}
}
/**
* 获取通知的额外信息(右侧显示)
*/
const getNotificationExtra = (notification: UnifiedNotification): { text: string; colorClass: string } | null => {
switch (notification.type) {
case 'diplomatic': {
const report = notification.data as DiplomaticReport
if (report.reputationChange === undefined) return null
return {
text: `${(report.reputationChange || 0) >= 0 ? '+' : ''}${report.reputationChange || 0}`,
colorClass: (report.reputationChange || 0) >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}
}
case 'trade': {
const offer = notification.data as TradeOffer
const isExpired = offer.expiresAt <= Date.now()
return {
text: isExpired ? t('npcBehavior.trade.expired') : t('diplomacy.notificationExtra.pending'),
colorClass: isExpired ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
}
}
case 'jointAttack': {
const invite = notification.data as JointAttackInvite
const isExpired = invite.expiresAt <= Date.now()
if (isExpired) {
return {
text: t('npcBehavior.jointAttack.expired'),
colorClass: 'text-red-600 dark:text-red-400'
}
}
return {
text: `${Math.round(invite.expectedLootRatio * 100)}%`,
colorClass: 'text-orange-600 dark:text-orange-400'
}
}
default:
return null
}
}
const getEventIcon = (eventType: DiplomaticReport['eventType']) => {
switch (eventType) {
@@ -346,21 +945,63 @@
return 'text-muted-foreground'
}
const handleReportClick = (report: DiplomaticReport) => {
/**
* 处理通知点击 - 标记为已读并打开详情弹窗
*/
const handleNotificationClick = (notification: UnifiedNotification) => {
// 标记为已读
report.read = true
// 设置选中的报告
selectedReport.value = report
// 关闭通知面板
isOpen.value = true
markNotificationAsRead(notification)
// 设置选中的通知
selectedNotification.value = notification
// 打开对话框
detailDialogOpen.value = true
}
/**
* 标记单个通知为已读
*/
const markNotificationAsRead = (notification: UnifiedNotification) => {
switch (notification.type) {
case 'diplomatic': {
const report = notification.data as DiplomaticReport
report.read = true
break
}
case 'trade': {
const offer = notification.data as TradeOffer
offer.read = true
break
}
case 'intel': {
const report = notification.data as IntelReport
report.read = true
break
}
case 'jointAttack': {
const invite = notification.data as JointAttackInvite
invite.read = true
break
}
}
}
const markAllAsRead = () => {
// 标记所有外交报告
gameStore.player.diplomaticReports?.forEach(report => {
report.read = true
})
// 标记所有贸易提议
gameStore.player.tradeOffers?.forEach(offer => {
offer.read = true
})
// 标记所有情报报告
gameStore.player.intelReports?.forEach(report => {
report.read = true
})
// 标记所有联合攻击邀请
gameStore.player.jointAttackInvites?.forEach(invite => {
invite.read = true
})
}
const goToDiplomacy = () => {
@@ -373,4 +1014,108 @@
detailDialogOpen.value = false
router.push(npcId ? `/diplomacy?npcId=${npcId}` : '/diplomacy')
}
const goToMessagesFromDialog = () => {
detailDialogOpen.value = false
isOpen.value = false
router.push('/messages')
}
// ========== 贸易提议详情方法 ==========
const getTradeNpcName = (offer: TradeOffer): string => {
return getNpcNameById(offer.npcId, offer.npcName)
}
const getTradeExpiresText = (offer: TradeOffer): string => {
const remaining = offer.expiresAt - currentTime.value
if (remaining <= 0) return t('npcBehavior.trade.expired')
return formatCountdown(remaining)
}
const getTradeExpiresClass = (offer: TradeOffer): string => {
const remaining = offer.expiresAt - currentTime.value
if (remaining <= 0) return 'text-red-600 dark:text-red-400'
if (remaining < 3600000) return 'text-orange-600 dark:text-orange-400' // < 1小时
return 'text-muted-foreground'
}
const getTradeRatioText = (offer: TradeOffer): string => {
const ratio = offer.offeredResources.amount / offer.requestedResources.amount
return `1:${ratio.toFixed(2)}`
}
// ========== 情报报告详情方法 ==========
const getIntelFromNpcName = (report: IntelReport): string => {
return getNpcNameById(report.fromNpcId, report.fromNpcName)
}
const getIntelTargetNpcName = (report: IntelReport): string => {
return getNpcNameById(report.targetNpcId, report.targetNpcName)
}
const getIntelTypeText = (report: IntelReport): string => {
switch (report.intelType) {
case 'enemyFleet':
return t('npcBehavior.intel.types.enemyFleet')
case 'enemyResources':
return t('npcBehavior.intel.types.enemyResources')
case 'enemyMovement':
return t('npcBehavior.intel.types.enemyMovement')
default:
return report.intelType
}
}
const getIntelTypeIcon = (report: IntelReport) => {
switch (report.intelType) {
case 'enemyFleet':
return Ship
case 'enemyResources':
return Coins
case 'enemyMovement':
return Navigation
default:
return FileSearch
}
}
const getIntelTypeIconColor = (report: IntelReport): string => {
switch (report.intelType) {
case 'enemyFleet':
return 'text-red-500'
case 'enemyResources':
return 'text-yellow-500'
case 'enemyMovement':
return 'text-blue-500'
default:
return 'text-cyan-500'
}
}
// ========== 联合攻击邀请详情方法 ==========
const getJointAttackFromNpcName = (invite: JointAttackInvite): string => {
return getNpcNameById(invite.fromNpcId, invite.fromNpcName)
}
const getJointAttackTargetNpcName = (invite: JointAttackInvite): string => {
return getNpcNameById(invite.targetNpcId, invite.targetNpcName)
}
const getJointAttackTargetPlanet = (invite: JointAttackInvite): string => {
const pos = invite.targetPosition
return `[${pos.galaxy}:${pos.system}:${pos.position}]`
}
const getJointAttackExpiresText = (invite: JointAttackInvite): string => {
const remaining = invite.expiresAt - currentTime.value
if (remaining <= 0) return t('npcBehavior.jointAttack.expired')
return formatCountdown(remaining)
}
const getJointAttackExpiresClass = (invite: JointAttackInvite): string => {
const remaining = invite.expiresAt - currentTime.value
if (remaining <= 0) return 'text-red-600 dark:text-red-400'
if (remaining < 3600000) return 'text-orange-600 dark:text-orange-400' // < 1小时
return 'text-muted-foreground'
}
</script>

View File

@@ -308,10 +308,10 @@ import * as resourceLogic from '@/logic/resourceLogic'
const getStatusDotClass = (item: BuildQueueItem | WaitingQueueItem): string => {
// 等待队列项根据资源是否足够显示不同颜色
if (isWaitingItem(item)) {
return isWaitingItemResourcesReady(item) ? 'bg-green-500' : 'bg-yellow-500'
return isWaitingItemResourcesReady(item) ? 'bg-green-500 dark:bg-green-400' : 'bg-yellow-500 dark:bg-yellow-400'
}
if (item.type === 'demolish') return 'bg-destructive'
if (item.type === 'technology') return 'bg-blue-500'
return 'bg-green-500'
if (item.type === 'technology') return 'bg-blue-500 dark:bg-blue-400'
return 'bg-green-500 dark:bg-green-400'
}
</script>

View File

@@ -7,9 +7,9 @@
<div
class="w-2 h-2 rounded-full shrink-0"
:class="{
'bg-green-500': status === RelationStatus.Friendly,
'bg-red-500': status === RelationStatus.Hostile,
'bg-gray-400': status === RelationStatus.Neutral
'bg-green-500 dark:bg-green-400': status === RelationStatus.Friendly,
'bg-red-500 dark:bg-red-400': status === RelationStatus.Hostile,
'bg-gray-400 dark:bg-gray-500': status === RelationStatus.Neutral
}"
/>
@@ -37,8 +37,8 @@
<!-- 好感度 -->
<div class="flex items-center gap-2 shrink-0">
<div class="w-16 h-1.5 bg-muted rounded-full overflow-hidden relative">
<div v-if="reputation < 0" class="h-full bg-red-500 absolute right-1/2" :style="{ width: `${Math.abs(reputation) / 2}%` }" />
<div v-if="reputation > 0" class="h-full bg-green-500 absolute left-1/2" :style="{ width: `${reputation / 2}%` }" />
<div v-if="reputation < 0" class="h-full bg-red-500 dark:bg-red-400 absolute right-1/2" :style="{ width: `${Math.abs(reputation) / 2}%` }" />
<div v-if="reputation > 0" class="h-full bg-green-500 dark:bg-green-400 absolute left-1/2" :style="{ width: `${reputation / 2}%` }" />
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-border" />
</div>
<span class="text-sm font-medium w-10 text-right" :class="reputationColor">{{ reputation > 0 ? '+' : '' }}{{ reputation }}</span>
@@ -72,9 +72,9 @@
<div
class="w-2 h-2 rounded-full shrink-0"
:class="{
'bg-green-500': status === RelationStatus.Friendly,
'bg-red-500': status === RelationStatus.Hostile,
'bg-gray-400': status === RelationStatus.Neutral
'bg-green-500 dark:bg-green-400': status === RelationStatus.Friendly,
'bg-red-500 dark:bg-red-400': status === RelationStatus.Hostile,
'bg-gray-400 dark:bg-gray-500': status === RelationStatus.Neutral
}"
/>
<div class="flex-1 min-w-0 flex items-center gap-1 flex-wrap">

View File

@@ -0,0 +1,180 @@
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>{{ t('settings.webdav.configTitle') }}</DialogTitle>
<DialogDescription>
{{ t('settings.webdav.configDesc') }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<!-- 服务器地址 -->
<div class="space-y-2">
<Label for="serverUrl">{{ t('settings.webdav.serverUrl') }}</Label>
<Input
id="serverUrl"
v-model="config.serverUrl"
:placeholder="t('settings.webdav.serverUrlPlaceholder')"
/>
<p class="text-xs text-muted-foreground">
{{ t('settings.webdav.serverUrlHint') }}
</p>
</div>
<!-- 用户名 -->
<div class="space-y-2">
<Label for="username">{{ t('settings.webdav.username') }}</Label>
<Input
id="username"
v-model="config.username"
:placeholder="t('settings.webdav.usernamePlaceholder')"
/>
</div>
<!-- 密码 -->
<div class="space-y-2">
<Label for="password">{{ t('settings.webdav.password') }}</Label>
<Input
id="password"
v-model="config.password"
type="password"
:placeholder="t('settings.webdav.passwordPlaceholder')"
/>
<p class="text-xs text-muted-foreground">
{{ t('settings.webdav.passwordHint') }}
</p>
</div>
<!-- 存档路径 -->
<div class="space-y-2">
<Label for="basePath">{{ t('settings.webdav.basePath') }}</Label>
<Input
id="basePath"
v-model="config.basePath"
:placeholder="t('settings.webdav.basePathPlaceholder')"
/>
</div>
<!-- 测试结果 -->
<div v-if="testResult" :class="['p-3 rounded-md text-sm', testResult.success ? 'bg-green-500/10 text-green-500' : 'bg-red-500/10 text-red-500']">
{{ testResult.message }}
</div>
</div>
<DialogFooter class="flex gap-2">
<Button variant="outline" @click="handleTest" :disabled="isTesting || !isConfigValid">
<Loader2 v-if="isTesting" class="mr-2 h-4 w-4 animate-spin" />
{{ t('settings.webdav.testConnection') }}
</Button>
<Button variant="destructive" v-if="hasExistingConfig" @click="handleClear">
{{ t('settings.webdav.clearConfig') }}
</Button>
<Button @click="handleSave" :disabled="!isConfigValid">
{{ t('common.save') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from '@/composables/useI18n'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Loader2 } from 'lucide-vue-next'
import {
type WebDAVConfig,
getWebDAVConfig,
saveWebDAVConfig,
clearWebDAVConfig,
testWebDAVConnection
} from '@/services/webdavService'
const { t } = useI18n()
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
saved: [config: WebDAVConfig]
cleared: []
}>()
const config = ref<WebDAVConfig>({
serverUrl: '',
username: '',
password: '',
basePath: '/ogame-saves/'
})
const isTesting = ref(false)
const testResult = ref<{ success: boolean; message: string } | null>(null)
const hasExistingConfig = ref(false)
const isConfigValid = computed(() => {
return config.value.serverUrl.trim() !== '' &&
config.value.username.trim() !== '' &&
config.value.password.trim() !== ''
})
// 加载已保存的配置
watch(isOpen, (open) => {
if (open) {
const saved = getWebDAVConfig()
if (saved) {
config.value = { ...saved }
hasExistingConfig.value = true
} else {
config.value = {
serverUrl: '',
username: '',
password: '',
basePath: '/ogame-saves/'
}
hasExistingConfig.value = false
}
testResult.value = null
}
})
const handleTest = async () => {
isTesting.value = true
testResult.value = null
try {
testResult.value = await testWebDAVConnection(config.value)
} finally {
isTesting.value = false
}
}
const handleSave = () => {
saveWebDAVConfig(config.value)
hasExistingConfig.value = true
emit('saved', config.value)
isOpen.value = false
}
const handleClear = () => {
clearWebDAVConfig()
hasExistingConfig.value = false
config.value = {
serverUrl: '',
username: '',
password: '',
basePath: '/ogame-saves/'
}
testResult.value = null
emit('cleared')
}
</script>

View File

@@ -0,0 +1,180 @@
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-lg">
<DialogHeader>
<DialogTitle>{{ t('settings.webdav.selectFile') }}</DialogTitle>
<DialogDescription>
{{ t('settings.webdav.selectFileDesc') }}
</DialogDescription>
</DialogHeader>
<div class="py-4">
<!-- 加载状态 -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="text-center py-8">
<p class="text-red-500 mb-4">{{ error }}</p>
<Button variant="outline" @click="loadFiles">
{{ t('common.retry') }}
</Button>
</div>
<!-- 空列表 -->
<div v-else-if="files.length === 0" class="text-center py-8 text-muted-foreground">
{{ t('settings.webdav.noFiles') }}
</div>
<!-- 文件列表 -->
<ScrollArea v-else class="h-[300px]">
<div class="space-y-2">
<div
v-for="file in files"
:key="file.name"
:class="[
'flex items-center justify-between p-3 rounded-md cursor-pointer transition-colors',
selectedFile === file.name ? 'bg-primary/10 border border-primary' : 'hover:bg-muted'
]"
@click="selectedFile = file.name"
>
<div class="flex-1 min-w-0">
<p class="font-medium truncate">{{ file.name }}</p>
<p class="text-xs text-muted-foreground">
{{ formatFileSize(file.size) }} · {{ formatDate(file.lastModified) }}
</p>
</div>
<Button
variant="ghost"
size="icon"
class="shrink-0 ml-2"
@click.stop="handleDelete(file.name)"
>
<Trash2 class="h-4 w-4 text-red-500" />
</Button>
</div>
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" @click="isOpen = false">
{{ t('common.cancel') }}
</Button>
<Button @click="handleSelect" :disabled="!selectedFile">
{{ t('settings.webdav.download') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from '@/composables/useI18n'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Loader2, Trash2 } from 'lucide-vue-next'
import {
type WebDAVConfig,
type WebDAVFile,
listWebDAVFiles,
deleteFromWebDAV
} from '@/services/webdavService'
import { toast } from 'vue-sonner'
const { t } = useI18n()
const props = defineProps<{
config: WebDAVConfig | null
}>()
const isOpen = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{
select: [fileName: string]
}>()
const files = ref<WebDAVFile[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const selectedFile = ref<string | null>(null)
const loadFiles = async () => {
if (!props.config) return
isLoading.value = true
error.value = null
selectedFile.value = null
try {
const result = await listWebDAVFiles(props.config)
if (result.success && result.files) {
files.value = result.files
} else {
error.value = result.message || t('settings.webdav.loadFailed')
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
isLoading.value = false
}
}
watch(isOpen, (open) => {
if (open && props.config) {
loadFiles()
}
})
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date)
}
const handleSelect = () => {
if (selectedFile.value) {
emit('select', selectedFile.value)
isOpen.value = false
}
}
const handleDelete = async (fileName: string) => {
if (!props.config) return
if (!confirm(t('settings.webdav.confirmDelete', { name: fileName }))) {
return
}
const result = await deleteFromWebDAV(props.config, fileName)
if (result.success) {
toast.success(t('settings.webdav.deleteSuccess'))
files.value = files.value.filter(f => f.name !== fileName)
if (selectedFile.value === fileName) {
selectedFile.value = null
}
} else {
toast.error(result.message || t('settings.webdav.deleteFailed'))
}
}
</script>

View File

@@ -94,7 +94,7 @@
// For slot content
defineSlots()
function generateStars(count: number, starColor: string) {
const generateStars = (count: number, starColor: string) => {
const shadows: string[] = []
for (let i = 0; i < count; i++) {
const x = Math.floor(Math.random() * 4000) - 2000
@@ -110,7 +110,7 @@
const springX = useSpring(offsetX, props.transition)
const springY = useSpring(offsetY, props.transition)
function handleMouseMove(e: MouseEvent) {
const handleMouseMove = (e: MouseEvent) => {
const centerX = window.innerWidth / 2
const centerY = window.innerHeight / 2
const newOffsetX = -(e.clientX - centerX) * props.factor

View File

@@ -0,0 +1,15 @@
<template>
<CollapsibleRoot v-slot="slotProps" data-slot="collapsible" v-bind="forwarded">
<slot v-bind="slotProps" />
</CollapsibleRoot>
</template>
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'reka-ui'
import { CollapsibleRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>

View File

@@ -0,0 +1,12 @@
<template>
<CollapsibleContent data-slot="collapsible-content" v-bind="props">
<slot />
</CollapsibleContent>
</template>
<script setup lang="ts">
import type { CollapsibleContentProps } from 'reka-ui'
import { CollapsibleContent } from 'reka-ui'
const props = defineProps<CollapsibleContentProps>()
</script>

View File

@@ -0,0 +1,12 @@
<template>
<CollapsibleTrigger data-slot="collapsible-trigger" v-bind="props">
<slot />
</CollapsibleTrigger>
</template>
<script setup lang="ts">
import type { CollapsibleTriggerProps } from 'reka-ui'
import { CollapsibleTrigger } from 'reka-ui'
const props = defineProps<CollapsibleTriggerProps>()
</script>

View File

@@ -0,0 +1,3 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'

View File

@@ -68,45 +68,7 @@
return `${r} ${g} ${b}`
})
onMounted(() => {
if (canvasRef.value) {
context.value = canvasRef.value.getContext('2d')
}
initCanvas()
animate()
window.addEventListener('resize', initCanvas)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', initCanvas)
})
watch([mouseX, mouseY], () => {
onMouseMove()
})
function initCanvas() {
resizeCanvas()
drawParticles()
}
function onMouseMove() {
if (canvasRef.value) {
const rect = canvasRef.value.getBoundingClientRect()
const { w, h } = canvasSize
const x = mouseX.value - rect.left - w / 2
const y = mouseY.value - rect.top - h / 2
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
if (inside) {
mouse.x = x
mouse.y = y
}
}
}
function resizeCanvas() {
const resizeCanvas = () => {
if (canvasContainerRef.value && canvasRef.value && context.value) {
circles.value.length = 0
canvasSize.w = canvasContainerRef.value.offsetWidth
@@ -119,7 +81,7 @@
}
}
function circleParams(): Circle {
const circleParams = (): Circle => {
const x = Math.floor(Math.random() * canvasSize.w)
const y = Math.floor(Math.random() * canvasSize.h)
const translateX = 0
@@ -144,7 +106,7 @@
}
}
function drawCircle(circle: Circle, update = false) {
const drawCircle = (circle: Circle, update = false) => {
if (context.value) {
const { x, y, translateX, translateY, size, alpha } = circle
context.value.translate(translateX, translateY)
@@ -160,13 +122,13 @@
}
}
function clearContext() {
const clearContext = () => {
if (context.value) {
context.value.clearRect(0, 0, canvasSize.w, canvasSize.h)
}
}
function drawParticles() {
const drawParticles = () => {
clearContext()
const particleCount = props.quantity
for (let i = 0; i < particleCount; i++) {
@@ -175,12 +137,32 @@
}
}
function remapValue(value: number, start1: number, end1: number, start2: number, end2: number): number {
const initCanvas = () => {
resizeCanvas()
drawParticles()
}
const onMouseMove = () => {
if (canvasRef.value) {
const rect = canvasRef.value.getBoundingClientRect()
const { w, h } = canvasSize
const x = mouseX.value - rect.left - w / 2
const y = mouseY.value - rect.top - h / 2
const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2
if (inside) {
mouse.x = x
mouse.y = y
}
}
}
const remapValue = (value: number, start1: number, end1: number, start2: number, end2: number): number => {
const remapped = ((value - start1) * (end2 - start2)) / (end1 - start1) + start2
return remapped > 0 ? remapped : 0
}
function animate() {
const animate = () => {
clearContext()
circles.value.forEach((circle, i) => {
// Handle the alpha value
@@ -235,4 +217,22 @@
})
window.requestAnimationFrame(animate)
}
onMounted(() => {
if (canvasRef.value) {
context.value = canvasRef.value.getContext('2d')
}
initCanvas()
animate()
window.addEventListener('resize', initCanvas)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', initCanvas)
})
watch([mouseX, mouseY], () => {
onMouseMove()
})
</script>