mirror of
https://github.com/setube/ogame-vue-ts.git
synced 2026-05-12 07:55:11 +08:00
docs: 新增西班牙语和日语README并优化多语言文档
新增README-ES.md(西班牙语)和README-JA.md(日语)文档,完善多语言README互链。优化各语言README徽章、技术栈、外链格式及语言切换区,提升文档一致性与可读性。
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
180
src/components/settings/WebDAVConfigDialog.vue
Normal file
180
src/components/settings/WebDAVConfigDialog.vue
Normal 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>
|
||||
180
src/components/settings/WebDAVFileListDialog.vue
Normal file
180
src/components/settings/WebDAVFileListDialog.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
15
src/components/ui/collapsible/Collapsible.vue
Normal file
15
src/components/ui/collapsible/Collapsible.vue
Normal 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>
|
||||
12
src/components/ui/collapsible/CollapsibleContent.vue
Normal file
12
src/components/ui/collapsible/CollapsibleContent.vue
Normal 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>
|
||||
12
src/components/ui/collapsible/CollapsibleTrigger.vue
Normal file
12
src/components/ui/collapsible/CollapsibleTrigger.vue
Normal 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>
|
||||
3
src/components/ui/collapsible/index.ts
Normal file
3
src/components/ui/collapsible/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user