refactor: 优化主界面布局与通知系统

重构App.vue,首页独立无侧边栏,其他页面采用统一侧边栏布局。新增右下角固定通知区,集成返回顶部、队列通知、外交通知和敌方警报。移除新手引导组件,替换为弱引导提示系统。支持星球重命名弹窗。优化NPC成长与行为定时器逻辑,提升性能和可维护性。删除issue模板及相关文档描述。
This commit is contained in:
谦君
2025-12-19 12:01:45 +08:00
parent a689ce21b7
commit 752cade67c
61 changed files with 5774 additions and 2817 deletions

View File

@@ -0,0 +1,48 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<Button v-if="isVisible" variant="outline" size="icon" @click="scrollToTop">
<ChevronUp class="h-4 w-4" />
</Button>
</Transition>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Button } from '@/components/ui/button'
import { ChevronUp } from 'lucide-vue-next'
// 显示阈值(滚动超过这个距离才显示按钮)
const SCROLL_THRESHOLD = 300
const isVisible = ref(false)
// 监听滚动事件
const handleScroll = () => {
isVisible.value = window.scrollY > SCROLL_THRESHOLD
}
// 丝滑返回顶部
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
// 初始检查
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>

View File

@@ -40,15 +40,9 @@
</div>
<!-- 胜利者 -->
<div class="text-center p-4 rounded-lg" :class="getWinnerStyle(report.winner)">
<div class="text-center p-4 rounded-lg" :class="getPlayerResultStyle()">
<p class="text-lg font-bold">
{{
report.winner === 'attacker'
? t('messagesView.victory')
: report.winner === 'defender'
? t('messagesView.defeat')
: t('messagesView.draw')
}}
{{ report.winner === 'draw' ? t('messagesView.draw') : isPlayerVictory ? t('messagesView.victory') : t('messagesView.defeat') }}
</p>
<p v-if="report.rounds" class="text-sm mt-1">{{ t('simulatorView.afterRounds').replace('{rounds}', String(report.rounds)) }}</p>
</div>
@@ -92,86 +86,88 @@
</div>
<!-- 剩余单位 -->
<div v-if="report.attackerRemaining || report.defenderRemaining" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-if="hasAnyRemaining" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 攻击方剩余 -->
<div v-if="report.attackerRemaining && Object.keys(report.attackerRemaining).length > 0" class="space-y-2">
<div class="space-y-2">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">{{ t('messagesView.attackerRemaining') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in report.attackerRemaining" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<template v-if="report.attackerRemaining && Object.keys(report.attackerRemaining).length > 0">
<div v-for="(count, shipType) in report.attackerRemaining" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
</template>
<p v-else class="text-muted-foreground">{{ t('messagesView.allDestroyed') }}</p>
</div>
</div>
<!-- 防守方剩余 -->
<div
v-if="
report.defenderRemaining &&
(Object.keys(report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(report.defenderRemaining.defense || {}).length > 0)
"
class="space-y-2"
>
<div class="space-y-2">
<p class="text-sm font-medium text-blue-600 dark:text-blue-400">{{ t('messagesView.defenderRemaining') }}</p>
<div class="p-3 bg-muted rounded-lg space-y-1 text-xs">
<div v-for="(count, shipType) in report.defenderRemaining.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<div v-for="(count, defenseType) in report.defenderRemaining.defense" :key="defenseType">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<template
v-if="
report.defenderRemaining &&
(Object.keys(report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(report.defenderRemaining.defense || {}).length > 0)
"
>
<div v-for="(count, shipType) in report.defenderRemaining.fleet" :key="shipType">
<span class="text-muted-foreground">{{ SHIPS[shipType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
<div v-for="(count, defenseType) in report.defenderRemaining.defense" :key="defenseType">
<span class="text-muted-foreground">{{ DEFENSES[defenseType].name }}:</span>
<span class="ml-2 font-medium">{{ count }}</span>
</div>
</template>
<p v-else class="text-muted-foreground">{{ t('messagesView.allDestroyed') }}</p>
</div>
</div>
</div>
<!-- 战利品和残骸 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- 掠夺资源 -->
<div
v-if="report.plunder && (report.plunder.metal > 0 || report.plunder.crystal > 0 || report.plunder.deuterium > 0)"
class="p-3 bg-green-50 dark:bg-green-950 rounded-lg"
>
<p class="text-sm font-medium mb-2 text-green-600 dark:text-green-400">{{ t('messagesView.plunder') }}</p>
<div class="flex flex-wrap gap-3 text-xs">
<span v-if="report.plunder.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.plunder.metal) }}
</span>
<span v-if="report.plunder.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.plunder.crystal) }}
</span>
<span v-if="report.plunder.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(report.plunder.deuterium) }}
</span>
</div>
<!-- 掠夺资源 -->
<div
v-if="report.plunder && (report.plunder.metal > 0 || report.plunder.crystal > 0 || report.plunder.deuterium > 0)"
class="p-3 bg-green-50 dark:bg-green-950 rounded-lg"
>
<p class="text-sm font-medium mb-2 text-green-600 dark:text-green-400">{{ t('messagesView.plunder') }}</p>
<div class="flex flex-wrap gap-3 text-xs justify-center">
<span v-if="report.plunder.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.plunder.metal) }}
</span>
<span v-if="report.plunder.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.plunder.crystal) }}
</span>
<span v-if="report.plunder.deuterium > 0" class="flex items-center gap-1">
<ResourceIcon type="deuterium" size="sm" />
{{ formatNumber(report.plunder.deuterium) }}
</span>
</div>
</div>
<!-- 残骸场 -->
<div
v-if="report.debrisField && (report.debrisField.metal > 0 || report.debrisField.crystal > 0)"
class="p-3 bg-muted rounded-lg"
>
<p class="text-sm font-medium mb-2">{{ t('messagesView.debrisField') }}</p>
<div class="flex flex-wrap gap-3 text-xs">
<span v-if="report.debrisField.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.debrisField.metal) }}
</span>
<span v-if="report.debrisField.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.debrisField.crystal) }}
</span>
</div>
<!-- 月球生成概率 -->
<p v-if="report.moonChance && report.moonChance > 0" class="text-xs text-muted-foreground mt-2">
{{ t('messagesView.moonChance') }}: {{ (report.moonChance * 100).toFixed(1) }}%
</p>
<!-- 残骸场 -->
<div
v-if="report.debrisField && (report.debrisField.metal > 0 || report.debrisField.crystal > 0)"
class="text-center p-4 bg-muted rounded-lg"
>
<p class="text-sm font-medium mb-2">{{ t('messagesView.debrisField') }}</p>
<div class="flex flex-wrap gap-3 text-xs justify-center">
<span v-if="report.debrisField.metal > 0" class="flex items-center gap-1">
<ResourceIcon type="metal" size="sm" />
{{ formatNumber(report.debrisField.metal) }}
</span>
<span v-if="report.debrisField.crystal > 0" class="flex items-center gap-1">
<ResourceIcon type="crystal" size="sm" />
{{ formatNumber(report.debrisField.crystal) }}
</span>
</div>
<!-- 月球生成概率 -->
<p v-if="report.moonChance && report.moonChance > 0" class="text-xs text-muted-foreground mt-2">
{{ t('messagesView.moonChance') }}: {{ (report.moonChance * 100).toFixed(1) }}%
</p>
</div>
<!-- 回合详情 -->
@@ -301,7 +297,11 @@
// 获取攻击方星球信息
const attackerPlanet = computed(() => {
if (!props.report) return null
return gameStore.player.planets.find(p => p.id === props.report!.attackerPlanetId)
// 先从玩家星球中查找
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.attackerPlanetId)
if (playerPlanet) return playerPlanet
// 再从宇宙星球地图中查找(包括 NPC 星球)
return Object.values(universeStore.planets).find(p => p.id === props.report!.attackerPlanetId)
})
// 获取防守方星球信息
@@ -310,10 +310,35 @@
// 先从玩家星球中查找
const playerPlanet = gameStore.player.planets.find(p => p.id === props.report!.defenderPlanetId)
if (playerPlanet) return playerPlanet
// 再从宇宙星球地图中查找
// 再从宇宙星球地图中查找(包括 NPC 星球)
return Object.values(universeStore.planets).find(p => p.id === props.report!.defenderPlanetId)
})
// 判断玩家是攻击方还是防守方
const isPlayerAttacker = computed(() => {
if (!props.report) return false
return gameStore.player.planets.some(p => p.id === props.report!.attackerPlanetId)
})
// 判断玩家是否胜利
const isPlayerVictory = computed(() => {
if (!props.report) return false
if (props.report.winner === 'draw') return false
// 玩家是攻击方且攻击方胜利,或者玩家是防守方且防守方胜利
return (isPlayerAttacker.value && props.report.winner === 'attacker') || (!isPlayerAttacker.value && props.report.winner === 'defender')
})
// 判断是否有任何剩余单位需要显示
const hasAnyRemaining = computed(() => {
if (!props.report) return false
const hasAttackerRemaining = props.report.attackerRemaining && Object.keys(props.report.attackerRemaining).length > 0
const hasDefenderRemaining =
props.report.defenderRemaining &&
(Object.keys(props.report.defenderRemaining.fleet || {}).length > 0 ||
Object.keys(props.report.defenderRemaining.defense || {}).length > 0)
return hasAttackerRemaining || hasDefenderRemaining
})
watch(
() => props.open,
newValue => {
@@ -328,10 +353,11 @@
emit('update:open', newValue)
})
// 获取胜利者样式
const getWinnerStyle = (winner: string) => {
if (winner === 'attacker') return 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300'
if (winner === 'defender') return 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
return 'bg-gray-50 dark:bg-gray-950 text-gray-700 dark:text-gray-300'
// 获取玩家战斗结果样式
const getPlayerResultStyle = () => {
if (!props.report) return 'bg-gray-50 dark:bg-gray-950 text-gray-700 dark:text-gray-300'
if (props.report.winner === 'draw') return 'bg-gray-50 dark:bg-gray-950 text-gray-700 dark:text-gray-300'
if (isPlayerVictory.value) return 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300'
return 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
}
</script>

View File

@@ -8,7 +8,7 @@
variant="destructive"
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
{{ unreadCount }}
</Badge>
</Button>
</PopoverTrigger>
@@ -20,38 +20,51 @@
</Button>
</div>
<ScrollArea class="h-96">
<div v-if="reports.length === 0" class="p-8 text-center text-muted-foreground">
{{ t('diplomacy.noReports') }}
</div>
<Empty v-if="reports.length === 0" class="border-0">
<EmptyContent>
<ScrollText class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('diplomacy.noReports') }}</EmptyDescription>
</EmptyContent>
</Empty>
<div v-else class="divide-y">
<div
v-for="report in reports"
:key="report.id"
class="p-4 hover:bg-muted/50 cursor-pointer transition-colors"
class="p-3 hover:bg-muted/50 cursor-pointer transition-colors"
:class="{ 'bg-primary/5': !report.read }"
@click="handleReportClick(report)"
>
<div class="flex items-start gap-3">
<div class="flex-shrink-0 mt-1">
<component :is="getEventIcon(report.eventType)" class="h-4 w-4" :class="getEventIconColor(report.eventType)" />
<div class="flex items-center gap-3">
<!-- 左侧事件图标 -->
<div class="flex-shrink-0">
<component :is="getEventIcon(report.eventType)" class="h-5 w-5" :class="getEventIconColor(report.eventType)" />
</div>
<!-- 中间主要信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium text-sm">{{ report.npcName }}</span>
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ report.npcName }}</span>
<Badge :variant="getStatusBadgeVariant(report.newStatus)" class="text-xs flex-shrink-0">
{{ getStatusText(report.newStatus) }}
</Badge>
<span v-if="!report.read" class="ml-auto">
<Badge variant="destructive" class="h-2 w-2 p-0 rounded-full" />
</span>
</div>
<p class="text-sm text-muted-foreground line-clamp-2">
{{ report.messageKey && report.messageParams ? t(report.messageKey, report.messageParams) : report.message }}
</p>
<p class="text-xs text-muted-foreground mt-1">
{{ formatRelativeTime((Date.now() - report.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
<p class="text-xs text-muted-foreground mt-0.5">
{{ getEventTypeText(report.eventType) }}
</p>
</div>
<!-- 右侧好感度变化和时间 -->
<div class="flex-shrink-0 text-right">
<span
class="text-sm font-bold block"
:class="report.reputationChange >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
>
{{ report.reputationChange >= 0 ? '+' : '' }}{{ report.reputationChange }}
</span>
<span class="text-[10px] text-muted-foreground">
{{ formatRelativeTime((Date.now() - report.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</span>
</div>
<!-- 未读标记 -->
<span v-if="!report.read" class="h-2 w-2 rounded-full bg-destructive flex-shrink-0" />
</div>
</div>
</div>
@@ -77,6 +90,9 @@
/>
{{ t('diplomacy.reportDetails') }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ t('diplomacy.reportDetails') }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedReport" class="space-y-4">
@@ -183,9 +199,10 @@
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
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 { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
import { RelationStatus, DiplomaticEventType } from '@/types/game'
import type { DiplomaticReport } from '@/types/game'
import { formatRelativeTime } from '@/utils/format'
@@ -241,6 +258,25 @@
}
}
const getEventTypeText = (eventType: DiplomaticReport['eventType']) => {
switch (eventType) {
case DiplomaticEventType.GiftResources:
return t('diplomacy.eventType.gift')
case DiplomaticEventType.Attack:
return t('diplomacy.eventType.attack')
case DiplomaticEventType.AllyAttacked:
return t('diplomacy.eventType.allyAttacked')
case DiplomaticEventType.Spy:
return t('diplomacy.eventType.spy')
case DiplomaticEventType.StealDebris:
return t('diplomacy.eventType.stealDebris')
case DiplomaticEventType.DestroyPlanet:
return t('diplomacy.eventType.destroyPlanet')
default:
return t('diplomacy.eventType.unknown')
}
}
const getStatusBadgeVariant = (status: RelationStatus) => {
switch (status) {
case RelationStatus.Hostile:
@@ -275,11 +311,12 @@
const handleReportClick = (report: DiplomaticReport) => {
// 标记为已读
report.read = true
// 设置选中的报告并打开详情对话框
// 设置选中的报告
selectedReport.value = report
detailDialogOpen.value = true
// 关闭通知面板
isOpen.value = false
isOpen.value = true
// 打开对话框
detailDialogOpen.value = true
}
const markAllAsRead = () => {
@@ -294,7 +331,8 @@
}
const goToDiplomacyFromDialog = () => {
const npcId = selectedReport.value?.npcId
detailDialogOpen.value = false
router.push('/diplomacy')
router.push(npcId ? `/diplomacy?npcId=${npcId}` : '/diplomacy')
}
</script>

View File

@@ -0,0 +1,304 @@
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<Button variant="outline" size="icon" class="relative">
<Siren class="h-4 w-4" />
<Badge
v-if="activeAlerts.length > 0"
variant="destructive"
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs animate-pulse"
>
{{ activeAlerts.length }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-96 p-0" align="end">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="font-semibold">{{ t('enemyAlert.title') }}</h3>
<Button v-if="activeAlerts.length > 0" variant="ghost" size="sm" @click="markAllAsRead">
{{ t('enemyAlert.markAllRead') }}
</Button>
</div>
<ScrollArea class="h-96">
<Empty v-if="activeAlerts.length === 0" class="border-0">
<EmptyContent>
<Shield class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('enemyAlert.noAlerts') }}</EmptyDescription>
</EmptyContent>
</Empty>
<div v-else class="divide-y">
<div
v-for="alert in activeAlerts"
:key="alert.id"
class="p-3 hover:bg-muted/50 cursor-pointer transition-colors"
:class="{ 'bg-destructive/10': !alert.read }"
@click="handleAlertClick(alert)"
>
<div class="flex items-center gap-3">
<!-- 左侧任务图标 -->
<div class="flex-shrink-0">
<component :is="getMissionIcon(alert.missionType)" class="h-5 w-5" :class="getMissionIconColor(alert.missionType)" />
</div>
<!-- 中间主要信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">{{ alert.npcName }}</span>
<Badge :variant="getMissionBadgeVariant(alert.missionType)" class="text-xs flex-shrink-0">
{{ getMissionTypeText(alert.missionType) }}
</Badge>
</div>
<p class="text-xs text-muted-foreground mt-0.5">
{{ alert.targetPlanetName }} · {{ t('enemyAlert.fleetSize') }}: {{ alert.fleetSize }}
</p>
</div>
<!-- 右侧倒计时 -->
<div class="flex-shrink-0 text-right">
<span class="text-sm font-bold block" :class="getRemainingTimeColor(alert)">
{{ formatRemainingTime(alert) }}
</span>
</div>
<!-- 未读标记 -->
<span v-if="!alert.read" class="h-2 w-2 rounded-full bg-destructive flex-shrink-0 animate-pulse" />
</div>
</div>
</div>
</ScrollArea>
<div v-if="activeAlerts.length > 0" class="p-2 border-t">
<Button variant="ghost" size="sm" class="w-full" @click="goToFleet">
{{ t('enemyAlert.viewFleet') }}
</Button>
</div>
</PopoverContent>
</Popover>
<!-- 警报详情对话框 -->
<Dialog :open="detailDialogOpen" @update:open="detailDialogOpen = $event">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<component
v-if="selectedAlert"
:is="getMissionIcon(selectedAlert.missionType)"
class="h-5 w-5"
:class="getMissionIconColor(selectedAlert.missionType)"
/>
{{ t('enemyAlert.alertDetails') }}
</DialogTitle>
<DialogDescription class="sr-only">
{{ t('enemyAlert.alertDetails') }}
</DialogDescription>
</DialogHeader>
<div v-if="selectedAlert" class="space-y-4">
<!-- 敌方信息 -->
<div class="flex items-center gap-3 p-4 bg-destructive/10 rounded-lg">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<h3 class="font-semibold text-lg">{{ selectedAlert.npcName }}</h3>
<Badge :variant="getMissionBadgeVariant(selectedAlert.missionType)">
{{ getMissionTypeText(selectedAlert.missionType) }}
</Badge>
</div>
<p class="text-sm text-muted-foreground">
{{ t('enemyAlert.fleetSize') }}: {{ selectedAlert.fleetSize }} {{ t('enemyAlert.ships') }}
</p>
</div>
</div>
<!-- 目标信息 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('enemyAlert.targetInfo') }}</h4>
<div class="p-3 bg-muted/30 rounded-md flex items-center gap-2">
<Globe class="h-4 w-4 text-blue-500" />
<span class="font-medium">{{ selectedAlert.targetPlanetName }}</span>
</div>
</div>
<!-- 到达时间 -->
<div class="space-y-2">
<h4 class="font-semibold text-sm">{{ t('enemyAlert.arrivalTime') }}</h4>
<div class="p-3 bg-muted/30 rounded-md">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{{ t('enemyAlert.countdown') }}</span>
<span class="font-bold text-lg" :class="getRemainingTimeColor(selectedAlert)">
{{ formatRemainingTime(selectedAlert) }}
</span>
</div>
<p class="text-xs text-muted-foreground mt-1">
{{ formatDate(selectedAlert.arrivalTime) }}
</p>
</div>
</div>
<!-- 警告提示 -->
<div class="p-3 bg-destructive/10 rounded-md border border-destructive/20">
<p class="text-sm text-destructive dark:text-red-400">
{{ getMissionWarningText(selectedAlert.missionType) }}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="detailDialogOpen = false">{{ t('common.close') }}</Button>
<Button @click="goToMessagesFromDialog">{{ t('enemyAlert.viewMessages') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
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 { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
import { Siren, Eye, Sword, Shield, Globe } from 'lucide-vue-next'
import { MissionType } from '@/types/game'
import type { IncomingFleetAlert } from '@/types/game'
import { formatDate, formatTime } from '@/utils/format'
const router = useRouter()
const gameStore = useGameStore()
const { t } = useI18n()
const isOpen = ref(false)
const detailDialogOpen = ref(false)
const selectedAlert = ref<IncomingFleetAlert | null>(null)
const currentTime = ref(Date.now())
let timeInterval: ReturnType<typeof setInterval> | null = null
// 启动计时器,用于实时更新倒计时
onMounted(() => {
timeInterval = setInterval(() => {
currentTime.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (timeInterval) {
clearInterval(timeInterval)
timeInterval = null
}
})
// 获取活跃的警报(未到达的)
const activeAlerts = computed(() => {
const now = currentTime.value
return (gameStore.player.incomingFleetAlerts || [])
.filter(alert => alert.arrivalTime > now)
.sort((a, b) => a.arrivalTime - b.arrivalTime) // 按到达时间排序
})
// 获取任务类型图标
const getMissionIcon = (missionType: MissionType) => {
switch (missionType) {
case MissionType.Spy:
return Eye
case MissionType.Attack:
return Sword
default:
return Siren
}
}
// 获取任务类型图标颜色
const getMissionIconColor = (missionType: MissionType) => {
switch (missionType) {
case MissionType.Spy:
return 'text-purple-500'
case MissionType.Attack:
return 'text-red-500'
default:
return 'text-yellow-500'
}
}
// 获取任务类型Badge样式
const getMissionBadgeVariant = (missionType: MissionType): 'destructive' | 'secondary' => {
return missionType === MissionType.Attack ? 'destructive' : 'secondary'
}
// 获取任务类型文本
const getMissionTypeText = (missionType: MissionType) => {
switch (missionType) {
case MissionType.Spy:
return t('enemyAlert.missionType.spy')
case MissionType.Attack:
return t('enemyAlert.missionType.attack')
default:
return t('enemyAlert.missionType.unknown')
}
}
// 获取任务警告文本
const getMissionWarningText = (missionType: MissionType) => {
switch (missionType) {
case MissionType.Spy:
return t('enemyAlert.warning.spy')
case MissionType.Attack:
return t('enemyAlert.warning.attack')
default:
return t('enemyAlert.warning.unknown')
}
}
// 格式化剩余时间
const formatRemainingTime = (alert: IncomingFleetAlert) => {
const remaining = Math.max(0, Math.floor((alert.arrivalTime - currentTime.value) / 1000))
if (remaining <= 0) {
return t('enemyAlert.arrived')
}
return formatTime(remaining)
}
// 获取剩余时间颜色
const getRemainingTimeColor = (alert: IncomingFleetAlert) => {
const remaining = alert.arrivalTime - currentTime.value
if (remaining <= 0) return 'text-red-600 dark:text-red-400 font-bold' // 已到达
if (remaining < 60000) return 'text-red-600 dark:text-red-400' // < 1分钟
if (remaining < 300000) return 'text-orange-600 dark:text-orange-400' // < 5分钟
return 'text-muted-foreground'
}
// 处理警报点击
const handleAlertClick = (alert: IncomingFleetAlert) => {
alert.read = true
selectedAlert.value = alert
isOpen.value = true
// 打开对话框
detailDialogOpen.value = true
}
// 标记所有为已读
const markAllAsRead = () => {
gameStore.player.incomingFleetAlerts?.forEach(alert => {
alert.read = true
})
}
// 跳转到舰队页面
const goToFleet = () => {
isOpen.value = false
router.push('/fleet')
}
// 从对话框跳转到消息页面
const goToMessagesFromDialog = () => {
detailDialogOpen.value = false
router.push('/messages')
}
// 打开弹窗(供外部调用)
const open = () => {
isOpen.value = true
}
// 暴露方法给父组件
defineExpose({
open
})
</script>

View File

@@ -0,0 +1,86 @@
<template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="-translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="-translate-y-4 opacity-0"
>
<div
v-if="isHintVisible && currentHint"
class="fixed top-2 right-2 max-w-[280px] sm:top-4 sm:right-4 sm:max-w-xs z-50 pointer-events-auto"
>
<div class="bg-card border rounded-lg shadow-lg p-3" role="alert" aria-live="polite">
<!-- 标题栏 -->
<div class="flex items-center gap-2 mb-2">
<component :is="getIcon(currentHint.icon)" class="h-4 w-4 text-primary flex-shrink-0" />
<h4 class="font-medium text-sm">{{ t(currentHint.titleKey) }}</h4>
</div>
<!-- 内容 -->
<p class="text-sm text-muted-foreground mb-3 line-clamp-3">{{ t(currentHint.messageKey) }}</p>
<!-- 操作按钮 -->
<div class="flex items-center justify-end">
<Button size="sm" class="text-xs h-7" @click="handleDismiss">
{{ t('hints.dontShowAgain') }}
</Button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { useHints } from '@/composables/useHints'
import { useI18n } from '@/composables/useI18n'
import { Button } from '@/components/ui/button'
import {
Home,
Building,
FlaskConical,
Rocket,
Plane,
Globe,
Handshake,
Mail,
Shield,
Lightbulb,
Users,
Swords,
Settings,
Wand2
} from 'lucide-vue-next'
import type { Component } from 'vue'
const { t } = useI18n()
const { currentHint, isHintVisible, dismissHint } = useHints()
// 图标名称到组件的映射
const iconMap: Record<string, Component> = {
home: Home,
building: Building,
flask: FlaskConical,
rocket: Rocket,
plane: Plane,
globe: Globe,
handshake: Handshake,
mail: Mail,
shield: Shield,
users: Users,
swords: Swords,
settings: Settings,
wand: Wand2
}
const getIcon = (iconName?: string) => {
if (!iconName) return Lightbulb
return iconMap[iconName] || Lightbulb
}
// 不再显示 - 永久关闭
const handleDismiss = () => {
dismissHint(true)
}
</script>

View File

@@ -1,67 +1,40 @@
<template>
<div v-if="alerts.length > 0" class="bg-destructive/10 border-b border-destructive/20">
<div class="px-4 sm:px-6 py-2 space-y-2 max-h-[230px] overflow-y-auto">
<div
v-for="alert in alerts"
:key="alert.id"
class="flex items-center justify-between gap-3 bg-destructive/5 rounded-lg px-3 py-2 border border-destructive/20"
>
<!-- 警告图标和信息 -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 animate-pulse" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-destructive truncate">
<template v-if="alert.missionType === 'spy'">
{{ t('alerts.npcSpyIncoming') }}
</template>
<template v-else-if="alert.missionType === 'attack'">
{{ t('alerts.npcAttackIncoming') }}
</template>
<template v-else>
{{ t('alerts.npcFleetIncoming') }}
</template>
</p>
<p class="text-xs text-muted-foreground truncate">
{{ alert.npcName }} {{ alert.targetPlanetName }}
<template v-if="alert.missionType === 'attack'">({{ alert.fleetSize }} {{ t('alerts.ships') }})</template>
</p>
</div>
</div>
<!-- 倒计时 -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="text-right">
<p class="text-xs font-mono text-destructive">
{{ formatTimeRemaining(alert.arrivalTime) }}
</p>
<p class="text-[10px] text-muted-foreground">
{{ formatTime(alert.arrivalTime) }}
</p>
</div>
<Button @click="markAsRead(alert)" variant="ghost" size="sm" class="h-6 w-6 p-0">
<X class="h-3 w-3" />
</Button>
<div v-if="activeAlerts.length > 0" class="bg-destructive/10 border-b border-destructive/20">
<div class="px-4 sm:px-6 py-2 flex items-center justify-between gap-3">
<!-- 警告图标和汇总信息 -->
<div class="flex items-center gap-2 flex-1 min-w-0">
<AlertTriangle class="h-5 w-5 text-destructive flex-shrink-0 animate-pulse" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-destructive">
{{ getAlertSummary() }}
</p>
<p class="text-xs text-muted-foreground">
{{ t('enemyAlert.countdown') }}: {{ formatTimeRemaining(nearestAlert?.arrivalTime || 0) }}
</p>
</div>
</div>
<!-- 查看按钮 -->
<Button @click="openAlertPanel" variant="outline" size="sm" class="flex-shrink-0">
{{ t('common.view') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import type { IncomingFleetAlert } from '@/types/game'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useGameStore } from '@/stores/gameStore'
import { Button } from '@/components/ui/button'
import { AlertTriangle, X } from 'lucide-vue-next'
import { AlertTriangle } from 'lucide-vue-next'
import { useI18n } from '@/composables/useI18n'
const props = defineProps<{
alerts: IncomingFleetAlert[]
}>()
import { MissionType } from '@/types/game'
const emit = defineEmits<{
(e: 'markAsRead', alert: IncomingFleetAlert): void
(e: 'openPanel'): void
}>()
const gameStore = useGameStore()
const { t } = useI18n()
// 强制更新倒计时
@@ -78,6 +51,46 @@
if (updateInterval) clearInterval(updateInterval)
})
// 获取活跃的警报(未到达的)
const activeAlerts = computed(() => {
return (gameStore.player.incomingFleetAlerts || [])
.filter(alert => alert.arrivalTime > now.value)
.sort((a, b) => a.arrivalTime - b.arrivalTime)
})
// 最近的警报
const nearestAlert = computed(() => activeAlerts.value[0] || null)
// 统计各类型警报数量
const alertCounts = computed(() => {
const counts = { spy: 0, attack: 0, other: 0 }
activeAlerts.value.forEach(alert => {
if (alert.missionType === MissionType.Spy) {
counts.spy++
} else if (alert.missionType === MissionType.Attack) {
counts.attack++
} else {
counts.other++
}
})
return counts
})
// 生成警报汇总文本
const getAlertSummary = (): string => {
const parts: string[] = []
if (alertCounts.value.attack > 0) {
parts.push(`${alertCounts.value.attack} ${t('enemyAlert.missionType.attack')}`)
}
if (alertCounts.value.spy > 0) {
parts.push(`${alertCounts.value.spy} ${t('enemyAlert.missionType.spy')}`)
}
if (alertCounts.value.other > 0) {
parts.push(`${alertCounts.value.other} ${t('enemyAlert.missionType.unknown')}`)
}
return t('alerts.incomingFleets', { count: activeAlerts.value.length }) + ': ' + parts.join(', ')
}
const formatTimeRemaining = (arrivalTime: number): string => {
const remaining = Math.max(0, arrivalTime - now.value)
const seconds = Math.floor((remaining / 1000) % 60)
@@ -90,12 +103,7 @@
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
const formatTime = (timestamp: number): string => {
const date = new Date(timestamp)
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const markAsRead = (alert: IncomingFleetAlert) => {
emit('markAsRead', alert)
const openAlertPanel = () => {
emit('openPanel')
}
</script>

View File

@@ -679,53 +679,56 @@
const baseCapacity = 10000
// Building calculation configuration
const buildingCalculations: Record<string, (level: number) => Partial<{
production: number
consumption: number
capacity: number
fleetStorage: number
spaceBonus: number
buildSpeedBonus: number
researchSpeedBonus: number
}>> = {
metalMine: (lvl) => ({
const buildingCalculations: Record<
string,
(level: number) => Partial<{
production: number
consumption: number
capacity: number
fleetStorage: number
spaceBonus: number
buildSpeedBonus: number
researchSpeedBonus: number
}>
> = {
metalMine: lvl => ({
production: Math.floor(1500 * lvl * Math.pow(1.5, lvl) * resourceBonus),
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
}),
crystalMine: (lvl) => ({
crystalMine: lvl => ({
production: Math.floor(1000 * lvl * Math.pow(1.5, lvl) * resourceBonus),
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
}),
deuteriumSynthesizer: (lvl) => ({
deuteriumSynthesizer: lvl => ({
production: Math.floor(500 * lvl * Math.pow(1.5, lvl) * resourceBonus),
consumption: Math.floor(10 * lvl * Math.pow(1.1, lvl))
}),
solarPlant: (lvl) => ({
solarPlant: lvl => ({
production: Math.floor(50 * lvl * Math.pow(1.1, lvl) * energyBonus)
}),
metalStorage: (lvl) => ({
metalStorage: lvl => ({
capacity: Math.floor(baseCapacity * Math.pow(2, lvl) * storageBonus)
}),
crystalStorage: (lvl) => ({
crystalStorage: lvl => ({
capacity: Math.floor(baseCapacity * Math.pow(2, lvl) * storageBonus)
}),
deuteriumTank: (lvl) => ({
deuteriumTank: lvl => ({
capacity: Math.floor(baseCapacity * Math.pow(2, lvl) * storageBonus)
}),
darkMatterCollector: (lvl) => ({
darkMatterCollector: lvl => ({
capacity: 1000 + lvl * 100,
production: Math.floor(25 * lvl * Math.pow(1.5, lvl))
}),
darkMatterTank: (lvl) => ({
darkMatterTank: lvl => ({
capacity: Math.floor(1000 * Math.pow(2, lvl) * storageBonus)
}),
fusionReactor: (lvl) => ({
fusionReactor: lvl => ({
production: Math.floor(150 * lvl * Math.pow(1.15, lvl))
}),
shipyard: (lvl) => ({
shipyard: lvl => ({
fleetStorage: 1000 * lvl
}),
hangar: (lvl) => ({
hangar: lvl => ({
fleetStorage: 500 * lvl
}),
terraformer: () => ({
@@ -734,13 +737,13 @@
lunarBase: () => ({
spaceBonus: 30
}),
roboticsFactory: (lvl) => ({
roboticsFactory: lvl => ({
buildSpeedBonus: lvl
}),
naniteFactory: (lvl) => ({
naniteFactory: lvl => ({
buildSpeedBonus: lvl * 2
}),
researchLab: (lvl) => ({
researchLab: lvl => ({
researchSpeedBonus: lvl
})
}

View File

@@ -1,109 +1,143 @@
<template>
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex-1">
<CardTitle class="flex items-center gap-2">
{{ npc.name }}
<Badge :variant="statusBadgeVariant">
{{ statusText }}
</Badge>
</CardTitle>
<CardDescription class="mt-1">
{{ npc.planets.length }} {{ t('diplomacy.planets') }}
<span v-if="npc.allies && npc.allies.length > 0" class="ml-2">· {{ npc.allies.length }} {{ t('diplomacy.allies') }}</span>
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent class="space-y-4">
<!-- 好感度进度条 -->
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('diplomacy.reputation') }}</span>
<span class="font-semibold" :class="reputationColor">{{ reputation > 0 ? '+' : '' }}{{ reputation }}</span>
</div>
<div class="relative">
<!-- 背景进度条 -->
<div class="h-2 bg-muted rounded-full overflow-hidden">
<!-- 负值部分左侧红色 -->
<div
v-if="reputation < 0"
class="h-full bg-red-500 dark:bg-red-600 absolute right-1/2"
:style="{ width: `${Math.abs(reputation) / 2}%` }"
/>
<!-- 正值部分右侧绿色 -->
<div
v-if="reputation > 0"
class="h-full bg-green-500 dark:bg-green-600 absolute left-1/2"
:style="{ width: `${reputation / 2}%` }"
/>
<div class="rounded-lg transition-shadow duration-300">
<Card>
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex-1">
<CardTitle class="flex items-center gap-2">
{{ npc.name }}
<span v-if="npc.note" class="text-muted-foreground font-normal">({{ npc.note }})</span>
<Badge :variant="statusBadgeVariant">
{{ statusText }}
</Badge>
</CardTitle>
<CardDescription class="mt-1">
{{ npc.planets.length }} {{ t('diplomacy.planets') }}
<span v-if="npc.allies && npc.allies.length > 0" class="ml-2">· {{ npc.allies.length }} {{ t('diplomacy.allies') }}</span>
</CardDescription>
</div>
<!-- 中心线 -->
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-border" />
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>-100</span>
<span>0</span>
<span>+100</span>
</div>
</div>
<!-- 盟友信息 -->
<div v-if="npc.allies && npc.allies.length > 0" class="pt-2 border-t">
<p class="text-sm text-muted-foreground mb-2">{{ t('diplomacy.alliedWith') }}:</p>
<div class="flex flex-wrap gap-1">
<Badge
v-for="allyId in npc.allies.slice(0, 3)"
:key="allyId"
variant="outline"
class="text-xs cursor-pointer hover:bg-accent transition-colors"
@click="scrollToAlly(allyId)"
<!-- 编辑备注按钮 -->
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click="openNoteDialog"
:title="npc.note ? t('diplomacy.actions.editNote') : t('diplomacy.actions.addNote')"
>
{{ getAllyName(allyId) }}
</Badge>
<Badge v-if="npc.allies.length > 3" variant="outline" class="text-xs">
+{{ npc.allies.length - 3 }} {{ t('diplomacy.more') }}
</Badge>
<Pencil class="h-4 w-4" />
</Button>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 pt-2">
<Button size="sm" variant="outline" class="flex-1" @click="handleGiftResources">
<Gift class="h-4 w-4 mr-2" />
{{ t('diplomacy.actions.gift') }}
</Button>
<Button size="sm" variant="outline" class="flex-1" @click="handleViewPlanets">
<Globe class="h-4 w-4 mr-2" />
{{ t('diplomacy.actions.viewPlanets') }}
</Button>
</div>
<!-- 最近活动 -->
<div v-if="recentEvent" class="pt-2 border-t">
<p class="text-xs text-muted-foreground mb-1">{{ t('diplomacy.lastEvent') }}:</p>
<div class="flex items-center gap-2 text-xs">
<component :is="getEventIcon(recentEvent.reason)" class="h-3 w-3" />
<span>{{ getEventText(recentEvent.reason) }}</span>
<span class="text-muted-foreground">
{{ formatRelativeTime((Date.now() - recentEvent.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</span>
</CardHeader>
<CardContent class="space-y-4">
<!-- 好感度进度条 -->
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">{{ t('diplomacy.reputation') }}</span>
<span class="font-semibold" :class="reputationColor">{{ reputation > 0 ? '+' : '' }}{{ reputation }}</span>
</div>
<div class="relative">
<!-- 背景进度条 -->
<div class="h-2 bg-muted rounded-full overflow-hidden">
<!-- 负值部分左侧红色 -->
<div
v-if="reputation < 0"
class="h-full bg-red-500 dark:bg-red-600 absolute right-1/2"
:style="{ width: `${Math.abs(reputation) / 2}%` }"
/>
<!-- 正值部分右侧绿色 -->
<div
v-if="reputation > 0"
class="h-full bg-green-500 dark:bg-green-600 absolute left-1/2"
:style="{ width: `${reputation / 2}%` }"
/>
</div>
<!-- 中心线 -->
<div class="absolute left-1/2 top-0 bottom-0 w-px bg-border" />
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>-100</span>
<span>0</span>
<span>+100</span>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 盟友信息 -->
<div v-if="npc.allies && npc.allies.length > 0" class="pt-2 border-t">
<p class="text-sm text-muted-foreground mb-2">{{ t('diplomacy.alliedWith') }}:</p>
<div class="flex flex-wrap gap-1">
<Badge
v-for="allyId in npc.allies.slice(0, 3)"
:key="allyId"
variant="outline"
class="text-xs cursor-pointer hover:bg-accent transition-colors"
:class="getAllyBorderClass(allyId)"
@click="scrollToAlly(allyId)"
>
{{ getAllyName(allyId) }}
</Badge>
<Badge v-if="npc.allies.length > 3" variant="outline" class="text-xs">
+{{ npc.allies.length - 3 }} {{ t('diplomacy.more') }}
</Badge>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2 pt-2">
<Button size="sm" variant="outline" class="flex-1" @click="handleGiftResources">
<Gift class="h-4 w-4 mr-2" />
{{ t('diplomacy.actions.gift') }}
</Button>
<Button size="sm" variant="outline" class="flex-1" @click="handleViewPlanets">
<Globe class="h-4 w-4 mr-2" />
{{ t('diplomacy.actions.viewPlanets') }}
</Button>
</div>
<!-- 最近活动 -->
<div v-if="recentEvent" class="pt-2 border-t">
<p class="text-xs text-muted-foreground mb-1">{{ t('diplomacy.lastEvent') }}:</p>
<div class="flex items-center gap-2 text-xs">
<component :is="getEventIcon(recentEvent.reason)" class="h-3 w-3" />
<span>{{ getEventText(recentEvent.reason) }}</span>
<span class="text-muted-foreground">
{{ formatRelativeTime((Date.now() - recentEvent.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</span>
</div>
</div>
</CardContent>
</Card>
<!-- 备注编辑对话框 -->
<Dialog v-model:open="noteDialogOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ npc.note ? t('diplomacy.actions.editNote') : t('diplomacy.actions.addNote') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('diplomacy.note') }}</DialogDescription>
</DialogHeader>
<div class="py-4">
<Input v-model="noteInput" :placeholder="t('diplomacy.notePlaceholder')" @keyup.enter="saveNote" />
</div>
<DialogFooter>
<Button variant="outline" @click="noteDialogOpen = false">{{ t('common.cancel') }}</Button>
<Button @click="saveNote">{{ t('common.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useNPCStore } from '@/stores/npcStore'
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Gift, Globe, Sword, Eye, Trash2 } from 'lucide-vue-next'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Gift, Globe, Sword, Eye, Trash2, Pencil } from 'lucide-vue-next'
import { RelationStatus, DiplomaticEventType } from '@/types/game'
import type { DiplomaticRelation, NPC } from '@/types/game'
import { formatRelativeTime } from '@/utils/format'
@@ -115,8 +149,28 @@
const router = useRouter()
const npcStore = useNPCStore()
const gameStore = useGameStore()
const { t } = useI18n()
// 备注对话框状态
const noteDialogOpen = ref(false)
const noteInput = ref('')
// 打开备注对话框
const openNoteDialog = () => {
noteInput.value = props.npc.note || ''
noteDialogOpen.value = true
}
// 保存备注
const saveNote = () => {
const npc = npcStore.npcs.find(n => n.id === props.npc.id)
if (npc) {
npc.note = noteInput.value.trim() || undefined
}
noteDialogOpen.value = false
}
// 好感度值
const reputation = computed(() => props.relation?.reputation || 0)
@@ -163,7 +217,26 @@
// 获取盟友名称
const getAllyName = (allyId: string) => {
const ally = npcStore.npcs.find(n => n.id === allyId)
return ally?.name || allyId.substring(0, 8)
if (!ally) return allyId.substring(0, 8)
return ally.note ? `${ally.name}(${ally.note})` : ally.name
}
// 获取盟友与玩家的外交关系状态对应的边框样式
const getAllyBorderClass = (allyId: string) => {
const ally = npcStore.npcs.find(n => n.id === allyId)
if (!ally) return ''
const allyRelation = ally.relations?.[gameStore.player.id]
if (!allyRelation) return '' // 无关系,使用默认边框
switch (allyRelation.status) {
case RelationStatus.Friendly:
return 'border-green-500 dark:border-green-400'
case RelationStatus.Hostile:
return 'border-red-500 dark:border-red-400'
default:
return '' // 中立,使用默认边框
}
}
// 获取事件图标

View File

@@ -0,0 +1,323 @@
<template>
<div class="rounded-lg transition-shadow duration-300">
<div class="p-3 rounded-lg border hover:bg-accent/50 transition-colors cursor-pointer" @click="toggleExpand">
<!-- 桌面端单行布局 -->
<div class="hidden sm:flex items-center gap-3">
<!-- 状态指示器 -->
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:class="{
'bg-green-500': status === RelationStatus.Friendly,
'bg-red-500': status === RelationStatus.Hostile,
'bg-gray-400': status === RelationStatus.Neutral
}"
/>
<!-- 名称和备注 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium truncate">{{ npc.name }}</span>
<span v-if="npc.note" class="text-muted-foreground text-sm truncate">({{ npc.note }})</span>
</div>
<div class="text-xs text-muted-foreground">
{{ npc.planets.length }} {{ t('diplomacy.planets') }}
<span v-if="npc.allies && npc.allies.length > 0">· {{ npc.allies.length }} {{ t('diplomacy.allies') }}</span>
</div>
</div>
<!-- 好感度 -->
<div class="flex items-center gap-2 flex-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 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>
</div>
<!-- 操作按钮 -->
<div class="flex items-center gap-1 flex-shrink-0">
<Button variant="ghost" size="icon" class="h-8 w-8" @click.stop="handleGiftResources" :title="t('diplomacy.actions.gift')">
<Gift class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8" @click.stop="handleViewPlanets" :title="t('diplomacy.actions.viewPlanets')">
<Globe class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click.stop="openNoteDialog"
:title="npc.note ? t('diplomacy.actions.editNote') : t('diplomacy.actions.addNote')"
>
<Pencil class="h-4 w-4" />
</Button>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform" :class="{ 'rotate-180': isExpanded }" />
</div>
</div>
<!-- 移动端两行布局 -->
<div class="sm:hidden space-y-2">
<!-- 第一行状态名称展开箭头 -->
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full flex-shrink-0"
:class="{
'bg-green-500': status === RelationStatus.Friendly,
'bg-red-500': status === RelationStatus.Hostile,
'bg-gray-400': status === RelationStatus.Neutral
}"
/>
<div class="flex-1 min-w-0">
<span class="font-medium truncate">{{ npc.name }}</span>
<span v-if="npc.note" class="text-muted-foreground text-sm ml-1">({{ npc.note }})</span>
</div>
<ChevronDown class="h-4 w-4 text-muted-foreground transition-transform flex-shrink-0" :class="{ 'rotate-180': isExpanded }" />
</div>
<!-- 第二行星球数好感度操作按钮 -->
<div class="flex items-center justify-between">
<div class="text-xs text-muted-foreground">
{{ npc.planets.length }} {{ t('diplomacy.planets') }}
<span v-if="npc.allies && npc.allies.length > 0">· {{ npc.allies.length }} {{ t('diplomacy.allies') }}</span>
</div>
<div class="flex items-center gap-1">
<!-- 好感度数值 -->
<span class="text-xs font-medium mr-1" :class="reputationColor">{{ reputation > 0 ? '+' : '' }}{{ reputation }}</span>
<!-- 操作按钮 -->
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="handleGiftResources" :title="t('diplomacy.actions.gift')">
<Gift class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click.stop="handleViewPlanets" :title="t('diplomacy.actions.viewPlanets')">
<Globe class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
@click.stop="openNoteDialog"
:title="npc.note ? t('diplomacy.actions.editNote') : t('diplomacy.actions.addNote')"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!-- 展开详情 -->
<div v-if="isExpanded" class="ml-5 pl-3 border-l-2 border-muted py-2 space-y-2">
<!-- 盟友信息 -->
<div v-if="npc.allies && npc.allies.length > 0" class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-muted-foreground">{{ t('diplomacy.alliedWith') }}:</span>
<Badge
v-for="allyId in npc.allies.slice(0, 5)"
:key="allyId"
variant="outline"
class="text-xs cursor-pointer hover:bg-accent transition-colors"
:class="getAllyBorderClass(allyId)"
@click="scrollToAlly(allyId)"
>
{{ getAllyName(allyId) }}
</Badge>
<Badge v-if="npc.allies.length > 5" variant="outline" class="text-xs">+{{ npc.allies.length - 5 }} {{ t('diplomacy.more') }}</Badge>
</div>
<!-- 最近活动 -->
<div v-if="recentEvent" class="flex items-center gap-2 text-xs">
<span class="text-muted-foreground">{{ t('diplomacy.lastEvent') }}:</span>
<component :is="getEventIcon(recentEvent.reason)" class="h-3 w-3" />
<span>{{ getEventText(recentEvent.reason) }}</span>
<span class="text-muted-foreground">
{{ formatRelativeTime((Date.now() - recentEvent.timestamp) / 1000, t) }}{{ t('diplomacy.ago') }}
</span>
</div>
</div>
<!-- 备注编辑对话框 -->
<Dialog v-model:open="noteDialogOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{{ npc.note ? t('diplomacy.actions.editNote') : t('diplomacy.actions.addNote') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('diplomacy.note') }}</DialogDescription>
</DialogHeader>
<div class="py-4">
<Input v-model="noteInput" :placeholder="t('diplomacy.notePlaceholder')" @keyup.enter="saveNote" />
</div>
<DialogFooter>
<Button variant="outline" @click="noteDialogOpen = false">{{ t('common.cancel') }}</Button>
<Button @click="saveNote">{{ t('common.save') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useNPCStore } from '@/stores/npcStore'
import { useGameStore } from '@/stores/gameStore'
import { useI18n } from '@/composables/useI18n'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Gift, Globe, Pencil, ChevronDown, Sword, Eye, Trash2 } from 'lucide-vue-next'
import { RelationStatus, DiplomaticEventType } from '@/types/game'
import type { DiplomaticRelation, NPC } from '@/types/game'
import { formatRelativeTime } from '@/utils/format'
const props = defineProps<{
npc: NPC
relation?: DiplomaticRelation
}>()
const router = useRouter()
const npcStore = useNPCStore()
const gameStore = useGameStore()
const { t } = useI18n()
// 展开状态
const isExpanded = ref(false)
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
// 备注对话框状态
const noteDialogOpen = ref(false)
const noteInput = ref('')
const openNoteDialog = () => {
noteInput.value = props.npc.note || ''
noteDialogOpen.value = true
}
const saveNote = () => {
const npc = npcStore.npcs.find(n => n.id === props.npc.id)
if (npc) {
npc.note = noteInput.value.trim() || undefined
}
noteDialogOpen.value = false
}
// 好感度值
const reputation = computed(() => props.relation?.reputation || 0)
// 关系状态
const status = computed(() => props.relation?.status || RelationStatus.Neutral)
// 好感度颜色
const reputationColor = computed(() => {
if (reputation.value >= 20) return 'text-green-600 dark:text-green-400'
if (reputation.value <= -20) return 'text-red-600 dark:text-red-400'
return 'text-muted-foreground'
})
// 最近的外交事件
const recentEvent = computed(() => {
if (!props.relation?.history || props.relation.history.length === 0) return null
return props.relation.history[props.relation.history.length - 1]
})
// 获取盟友名称
const getAllyName = (allyId: string) => {
const ally = npcStore.npcs.find(n => n.id === allyId)
if (!ally) return allyId.substring(0, 8)
return ally.note ? `${ally.name}(${ally.note})` : ally.name
}
// 获取盟友边框样式
const getAllyBorderClass = (allyId: string) => {
const ally = npcStore.npcs.find(n => n.id === allyId)
if (!ally) return ''
const allyRelation = ally.relations?.[gameStore.player.id]
if (!allyRelation) return ''
switch (allyRelation.status) {
case RelationStatus.Friendly:
return 'border-green-500 dark:border-green-400'
case RelationStatus.Hostile:
return 'border-red-500 dark:border-red-400'
default:
return ''
}
}
// 获取事件图标
const getEventIcon = (eventType: string) => {
switch (eventType) {
case DiplomaticEventType.GiftResources:
return Gift
case DiplomaticEventType.Attack:
case DiplomaticEventType.AllyAttacked:
return Sword
case DiplomaticEventType.Spy:
return Eye
case DiplomaticEventType.StealDebris:
return Trash2
default:
return Gift
}
}
// 获取事件文本
const getEventText = (eventType: string) => {
switch (eventType) {
case DiplomaticEventType.GiftResources:
return t('diplomacy.events.gift')
case DiplomaticEventType.Attack:
return t('diplomacy.events.attack')
case DiplomaticEventType.AllyAttacked:
return t('diplomacy.events.allyAttacked')
case DiplomaticEventType.Spy:
return t('diplomacy.events.spy')
case DiplomaticEventType.StealDebris:
return t('diplomacy.events.stealDebris')
default:
return eventType
}
}
// 赠送资源
const handleGiftResources = () => {
if (props.npc.planets.length > 0) {
const targetPlanet = props.npc.planets[0]
if (!targetPlanet) return
router.push({
path: '/fleet',
query: {
galaxy: targetPlanet.position.galaxy,
system: targetPlanet.position.system,
position: targetPlanet.position.position,
gift: '1'
}
})
}
}
// 查看星球
const handleViewPlanets = () => {
if (props.npc.planets.length > 0) {
const targetPlanet = props.npc.planets[0]
if (!targetPlanet) return
router.push({
path: '/galaxy',
query: {
galaxy: targetPlanet.position.galaxy,
system: targetPlanet.position.system,
highlightNpc: props.npc.id
}
})
}
}
// 滚动到盟友卡片
const scrollToAlly = (allyId: string) => {
const event = new CustomEvent('scrollToNpc', { detail: { npcId: allyId }, bubbles: true })
document.dispatchEvent(event)
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<Dialog v-model:open="open">
<DialogContent class="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{{ t('privacy.title') }}</DialogTitle>
<DialogDescription class="sr-only">{{ t('privacy.title') }}</DialogDescription>
</DialogHeader>
<div class="flex-1 overflow-y-auto pr-2 space-y-4 text-sm">
<!-- 简介 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.introduction.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.introduction.content') }}</p>
</section>
<!-- 数据收集 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.dataCollection.title') }}</h3>
<p class="text-muted-foreground mb-1">{{ t('privacy.sections.dataCollection.content') }}</p>
<ul class="list-disc list-inside text-muted-foreground ml-2 space-y-0.5">
<li>{{ t('privacy.sections.dataCollection.items.gameProgress') }}</li>
<li>{{ t('privacy.sections.dataCollection.items.settings') }}</li>
<li>{{ t('privacy.sections.dataCollection.items.language') }}</li>
</ul>
</section>
<!-- 数据存储 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.dataStorage.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.dataStorage.content') }}</p>
</section>
<!-- 无服务器通信 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.noServer.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.noServer.content') }}</p>
</section>
<!-- 第三方服务 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.thirdParty.title') }}</h3>
<p class="text-muted-foreground">{{ t('privacy.sections.thirdParty.content') }}</p>
</section>
<!-- 数据控制 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.dataControl.title') }}</h3>
<p class="text-muted-foreground mb-1">{{ t('privacy.sections.dataControl.content') }}</p>
<ul class="list-disc list-inside text-muted-foreground ml-2 space-y-0.5">
<li>{{ t('privacy.sections.dataControl.items.export') }}</li>
<li>{{ t('privacy.sections.dataControl.items.import') }}</li>
<li>{{ t('privacy.sections.dataControl.items.delete') }}</li>
</ul>
</section>
<!-- 联系我们 -->
<section>
<h3 class="font-semibold mb-1">{{ t('privacy.sections.contact.title') }}</h3>
<p class="text-muted-foreground">
{{ t('privacy.sections.contact.content') }}
<a
:href="`https://github.com/${pkg.author.name}/${pkg.name}/issues`"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>
GitHub Issues
</a>
</p>
</section>
</div>
<DialogFooter class="mt-4">
<Button variant="outline" @click="open = false">
{{ t('common.close') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import pkg from '../../package.json'
// 双向绑定 open 状态
const open = defineModel<boolean>('open', { default: false })
const { t } = useI18n()
</script>

View File

@@ -8,86 +8,83 @@
variant="default"
class="absolute -top-1 -right-1 h-5 w-5 p-0 flex items-center justify-center text-xs"
>
{{ totalQueueCount > 9 ? '9+' : totalQueueCount }}
{{ totalQueueCount }}
</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-96 p-0" align="end">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="font-semibold">{{ t('queue.title') }}</h3>
<h3 class="font-semibold">{{ t('queue.title') }} ({{ totalQueueCount }})</h3>
</div>
<ScrollArea class="h-[480px]">
<div v-if="totalQueueCount === 0" class="p-8 text-center text-muted-foreground">
{{ t('queue.empty') }}
</div>
<div v-else class="divide-y p-4 space-y-3">
<!-- 建造队列 -->
<div v-for="item in buildQueue" :key="item.id" class="space-y-1.5">
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
<div
class="h-2 w-2 rounded-full animate-pulse flex-shrink-0"
:class="item.type === 'demolish' ? 'bg-destructive' : 'bg-green-500'"
/>
<span class="font-medium truncate">{{ getItemName(item) }}</span>
<span class="text-muted-foreground text-[10px] sm:text-xs">
<template v-if="item.type === 'ship' || item.type === 'defense'">
{{ t('queue.quantity') }} {{ item.quantity }}
</template>
<template v-else-if="item.type === 'demolish'"> {{ t('queue.demolishing') }}</template>
<template v-else> {{ t('queue.level') }} {{ item.targetLevel }}</template>
</span>
</div>
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
{{ formatTime(getRemainingTime(item)) }}
</span>
<Button @click="handleCancel(item)" variant="ghost" size="sm" class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs">
{{ t('queue.cancel') }}
</Button>
</div>
</div>
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
</div>
<!-- 研究队列 -->
<div v-for="item in researchQueue" :key="item.id" class="space-y-1.5">
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
<div class="h-2 w-2 rounded-full bg-blue-500 animate-pulse flex-shrink-0" />
<span class="font-medium truncate">{{ getItemName(item) }}</span>
<span class="text-muted-foreground text-[10px] sm:text-xs"> {{ t('queue.level') }} {{ item.targetLevel }}</span>
</div>
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
{{ formatTime(getRemainingTime(item)) }}
</span>
<Button
@click="handleCancelResearch(item.id)"
variant="ghost"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
>
{{ t('queue.cancel') }}
</Button>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="w-full grid grid-cols-5 h-9 rounded-none border-b bg-transparent">
<TabsTrigger v-for="tab in tabConfig" :key="tab.value" :value="tab.value" class="text-xs px-1 data-[state=active]:bg-muted">
{{ t(`queue.tabs.${tab.value}`) }}
<Badge v-if="tab.items.length > 0" variant="secondary" class="ml-1 h-4 px-1 text-[10px]">
{{ tab.items.length }}
</Badge>
</TabsTrigger>
</TabsList>
<ScrollArea class="h-[420px]">
<TabsContent v-for="tab in tabConfig" :key="tab.value" :value="tab.value" class="mt-0">
<Empty v-if="tab.items.length === 0" class="border-0">
<EmptyContent>
<Inbox class="h-10 w-10 text-muted-foreground" />
<EmptyDescription>{{ t('queue.empty') }}</EmptyDescription>
</EmptyContent>
</Empty>
<div v-else class="divide-y p-4 space-y-3">
<div v-for="item in tab.items" :key="item.id" class="space-y-1.5">
<div class="flex items-center justify-between text-xs sm:text-sm gap-2">
<div class="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-1">
<div class="h-2 w-2 rounded-full animate-pulse flex-shrink-0" :class="getStatusDotClass(item)" />
<span class="font-medium truncate">{{ getItemName(item) }}</span>
<span class="text-muted-foreground text-[10px] sm:text-xs">
{{
item.type === 'ship' || item.type === 'defense'
? `${t('queue.quantity')} ${item.quantity}`
: item.type === 'demolish'
? `${t('queue.demolishing')}`
: `${t('queue.level')} ${item.targetLevel}`
}}
</span>
</div>
<div class="flex items-center gap-2 sm:gap-3 flex-shrink-0">
<span class="text-muted-foreground text-[10px] sm:text-xs whitespace-nowrap">
{{ formatTime(getRemainingTime(item)) }}
</span>
<Button
variant="ghost"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-[10px] sm:text-xs"
@click.stop="handleCancel(item)"
>
{{ t('queue.cancel') }}
</Button>
</div>
</div>
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
</div>
</div>
<Progress :model-value="getQueueProgress(item)" class="h-1.5" />
</div>
</div>
</ScrollArea>
</TabsContent>
</ScrollArea>
</Tabs>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ListOrdered } from 'lucide-vue-next'
import { computed, ref, onUnmounted, watch } from 'vue'
import { ListOrdered, Inbox } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Empty, EmptyContent, EmptyDescription } from '@/components/ui/empty'
import { useGameStore } from '@/stores/gameStore'
import { useGameConfig } from '@/composables/useGameConfig'
import { useI18n } from '@/composables/useI18n'
@@ -99,6 +96,35 @@
const { BUILDINGS, SHIPS, DEFENSES, TECHNOLOGIES } = useGameConfig()
const isOpen = ref(false)
const activeTab = ref('all')
// 响应式时间戳,用于驱动时间和进度的动态更新
const currentTime = ref(Date.now())
let timerInterval: ReturnType<typeof setInterval> | null = null
// 当弹窗打开时启动计时器,关闭时停止
watch(isOpen, open => {
if (open) {
// 启动每秒更新的计时器
timerInterval = setInterval(() => {
currentTime.value = Date.now()
}, 1000)
} else {
// 停止计时器
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
}
})
// 组件卸载时清理计时器
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval)
timerInterval = null
}
})
// 获取当前星球的建造队列
const buildQueue = computed(() => {
@@ -115,6 +141,15 @@
return buildQueue.value.length + researchQueue.value.length
})
// 标签页配置(用于循环渲染)
const tabConfig = computed(() => [
{ value: 'all', items: [...buildQueue.value, ...researchQueue.value] },
{ value: 'buildings', items: buildQueue.value.filter(item => item.type === 'building' || item.type === 'demolish') },
{ value: 'research', items: researchQueue.value },
{ value: 'ships', items: buildQueue.value.filter(item => item.type === 'ship') },
{ value: 'defense', items: buildQueue.value.filter(item => item.type === 'defense') }
])
// 获取队列项名称
const getItemName = (item: BuildQueueItem): string => {
if (item.type === 'building' || item.type === 'demolish') {
@@ -129,16 +164,14 @@
return ''
}
// 获取剩余时间
// 获取剩余时间(使用响应式 currentTime 确保动态更新)
const getRemainingTime = (item: BuildQueueItem): number => {
const now = Date.now()
return Math.max(0, Math.floor((item.endTime - now) / 1000))
return Math.max(0, Math.floor((item.endTime - currentTime.value) / 1000))
}
// 获取队列进度
// 获取队列进度(使用响应式 currentTime 确保动态更新)
const getQueueProgress = (item: BuildQueueItem): number => {
const now = Date.now()
const elapsed = now - item.startTime
const elapsed = currentTime.value - item.startTime
const total = item.endTime - item.startTime
return Math.min(100, (elapsed / total) * 100)
}
@@ -158,9 +191,10 @@
window.dispatchEvent(event)
}
// 取消研究
const handleCancelResearch = (queueId: string) => {
const event = new CustomEvent('cancel-research', { detail: queueId })
window.dispatchEvent(event)
// 获取状态指示点颜色
const getStatusDotClass = (item: BuildQueueItem): string => {
if (item.type === 'demolish') return 'bg-destructive'
if (item.type === 'technology') return 'bg-blue-500'
return 'bg-green-500'
}
</script>

View File

@@ -1,438 +0,0 @@
<template>
<Teleport to="body">
<div v-if="tutorialState.isActive && currentStep" class="tutorial-overlay">
<!-- Dark overlay parts (4 rectangles around the highlight) -->
<template v-if="highlightRect && currentStep.target">
<!-- Top overlay -->
<div
class="tutorial-backdrop-part"
:style="{
top: '0',
left: '0',
width: '100%',
height: `${highlightRect.top}px`
}"
/>
<!-- Bottom overlay -->
<div
class="tutorial-backdrop-part"
:style="{
top: `${highlightRect.bottom}px`,
left: '0',
width: '100%',
height: `calc(100% - ${highlightRect.bottom}px)`
}"
/>
<!-- Left overlay -->
<div
class="tutorial-backdrop-part"
:style="{
top: `${highlightRect.top}px`,
left: '0',
width: `${highlightRect.left}px`,
height: `${highlightRect.height}px`
}"
/>
<!-- Right overlay -->
<div
class="tutorial-backdrop-part"
:style="{
top: `${highlightRect.top}px`,
left: `${highlightRect.right}px`,
width: `calc(100% - ${highlightRect.right}px)`,
height: `${highlightRect.height}px`
}"
/>
<!-- Highlight border -->
<div
class="tutorial-highlight-border"
:style="{
top: `${highlightRect.top}px`,
left: `${highlightRect.left}px`,
width: `${highlightRect.width}px`,
height: `${highlightRect.height}px`
}"
/>
</template>
<!-- Full overlay for center placement (no target) -->
<div v-else class="tutorial-backdrop-full" />
<!-- Tutorial tooltip -->
<div
v-if="tooltipPosition"
class="tutorial-tooltip"
:class="`tutorial-tooltip-${currentStep.placement || 'center'}`"
:style="{
top: tooltipPosition.top,
left: tooltipPosition.left,
transform: tooltipPosition.transform
}"
>
<Card class="tutorial-card">
<CardHeader class="pb-3">
<div class="flex items-center justify-between">
<CardTitle class="text-lg">{{ t(currentStep.title) }}</CardTitle>
<Button v-if="currentStep.canSkip" variant="ghost" size="icon" class="h-6 w-6" @click="skipTutorial">
<XIcon :size="16" />
</Button>
</div>
<CardDescription class="text-sm mt-2">
{{ t(currentStep.content) }}
</CardDescription>
</CardHeader>
<CardContent class="pt-0 space-y-3">
<!-- Progress bar -->
<div class="space-y-1">
<div class="flex justify-between text-xs text-muted-foreground">
<span>{{ t('tutorial.progress') }}</span>
<span>{{ tutorialState.currentStepIndex + 1 }} / {{ totalSteps }}</span>
</div>
<div class="w-full bg-secondary rounded-full h-1.5">
<div class="bg-primary h-1.5 rounded-full transition-all duration-300" :style="{ width: `${progress}%` }" />
</div>
</div>
<!-- Navigation buttons -->
<div class="flex gap-2">
<Button v-if="tutorialState.currentStepIndex > 0" variant="outline" size="sm" @click="previousStep">
<ChevronLeftIcon :size="16" class="mr-1" />
{{ t('tutorial.previous') }}
</Button>
<Button v-if="!isLastStep" class="ml-auto" size="sm" @click="handleNext" :disabled="!canProceed">
{{ t('tutorial.next') }}
<ChevronRightIcon :size="16" class="ml-1" />
</Button>
<Button v-else class="ml-auto" size="sm" @click="completeTutorial">
{{ t('tutorial.completeButton') }}
<CheckIcon :size="16" class="ml-1" />
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useTutorial, getTutorialSteps } from '@/composables/useTutorial'
import { useI18n } from '@/composables/useI18n'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { XIcon, ChevronLeftIcon, ChevronRightIcon, CheckIcon } from 'lucide-vue-next'
const { t } = useI18n()
const { tutorialState, currentStep, progress, isLastStep, nextStep, previousStep, skipTutorial, completeTutorial } = useTutorial()
const highlightRect = ref<DOMRect | null>(null)
const tooltipPosition = ref<{ top: string; left: string; transform: string } | null>(null)
const totalSteps = computed(() => getTutorialSteps().length)
const isMobile = ref(false)
// Check if current step can proceed
const canProceed = computed(() => {
if (!currentStep.value) return false
// 所有步骤都允许手动点击下一步
return true
})
// 检测是否为移动端
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
// Calculate highlight and tooltip positions
const updatePositions = () => {
if (!currentStep.value) {
highlightRect.value = null
tooltipPosition.value = null
return
}
// 检测移动端
checkMobile()
// For center placement, no target element needed
if (!currentStep.value.target || currentStep.value.placement === 'center') {
highlightRect.value = null
tooltipPosition.value = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
return
}
// Find target element
const targetElement = document.querySelector(currentStep.value.target)
if (!targetElement) {
// Fallback to center if target not found
highlightRect.value = null
tooltipPosition.value = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
return
}
// Auto-scroll target element into view
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
})
// Get target element rect
const rect = targetElement.getBoundingClientRect()
const padding = currentStep.value.highlightPadding || 8
// Set highlight rect with padding
highlightRect.value = new DOMRect(rect.left - padding, rect.top - padding, rect.width + padding * 2, rect.height + padding * 2)
// 获取视口尺寸
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// 气泡的预估尺寸(根据视口大小响应式调整)
const tooltipWidth = isMobile.value ? Math.min(viewportWidth - 32, 360) : 480
const tooltipHeight = isMobile.value ? 280 : 300 // 预估高度
// 计算各个方向的可用空间
const spaceTop = rect.top
const spaceBottom = viewportHeight - rect.bottom
const spaceLeft = rect.left
const spaceRight = viewportWidth - rect.right
const tooltipOffset = isMobile.value ? 8 : 16 // 移动端使用更小的间距
const edgeMargin = isMobile.value ? 8 : 16 // 距离边缘的最小距离
// 根据优先级和可用空间自动选择最佳位置
let placement = currentStep.value.placement || 'bottom'
let finalPosition: { top: string; left: string; transform: string }
// 移动端优先使用 bottom 或 top 位置
if (isMobile.value) {
// 移动端强制使用 top/bottom忽略 left/right
if (placement === 'left' || placement === 'right') {
placement = spaceBottom > spaceTop ? 'bottom' : 'top'
}
}
// 智能位置选择:如果指定位置空间不足,自动调整
const canFitTop = spaceTop >= tooltipHeight + tooltipOffset + edgeMargin
const canFitBottom = spaceBottom >= tooltipHeight + tooltipOffset + edgeMargin
const canFitLeft = spaceLeft >= tooltipWidth + tooltipOffset + edgeMargin
const canFitRight = spaceRight >= tooltipWidth + tooltipOffset + edgeMargin
// 自动调整位置
if (placement === 'top' && !canFitTop && canFitBottom) {
placement = 'bottom'
} else if (placement === 'bottom' && !canFitBottom && canFitTop) {
placement = 'top'
} else if (placement === 'left' && !canFitLeft && canFitRight) {
placement = 'right'
} else if (placement === 'right' && !canFitRight && canFitLeft) {
placement = 'left'
}
// 计算位置
switch (placement) {
case 'top': {
let left = rect.left + rect.width / 2
// 确保不超出左右边界
left = Math.max(tooltipWidth / 2 + edgeMargin, Math.min(left, viewportWidth - tooltipWidth / 2 - edgeMargin))
finalPosition = {
top: `${Math.max(edgeMargin, rect.top - tooltipOffset)}px`,
left: `${left}px`,
transform: 'translate(-50%, -100%)'
}
break
}
case 'bottom': {
let left = rect.left + rect.width / 2
// 确保不超出左右边界
left = Math.max(tooltipWidth / 2 + edgeMargin, Math.min(left, viewportWidth - tooltipWidth / 2 - edgeMargin))
finalPosition = {
top: `${Math.min(viewportHeight - tooltipHeight - edgeMargin, rect.bottom + tooltipOffset)}px`,
left: `${left}px`,
transform: 'translate(-50%, 0)'
}
break
}
case 'left': {
let top = rect.top + rect.height / 2
// 确保不超出上下边界
top = Math.max(tooltipHeight / 2 + edgeMargin, Math.min(top, viewportHeight - tooltipHeight / 2 - edgeMargin))
finalPosition = {
top: `${top}px`,
left: `${Math.max(edgeMargin, rect.left - tooltipOffset)}px`,
transform: 'translate(-100%, -50%)'
}
break
}
case 'right': {
let top = rect.top + rect.height / 2
// 确保不超出上下边界
top = Math.max(tooltipHeight / 2 + edgeMargin, Math.min(top, viewportHeight - tooltipHeight / 2 - edgeMargin))
finalPosition = {
top: `${top}px`,
left: `${Math.min(viewportWidth - tooltipWidth - edgeMargin, rect.right + tooltipOffset)}px`,
transform: 'translate(0, -50%)'
}
break
}
default:
finalPosition = {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
}
tooltipPosition.value = finalPosition
}
// Handle next step
const handleNext = () => {
if (canProceed.value) {
nextStep()
}
}
// Update positions when step changes
watch(
() => currentStep.value,
() => {
// Wait for DOM update and route change
setTimeout(() => {
updatePositions()
}, 100)
},
{ immediate: true }
)
// Update positions on window resize or scroll
const handleResize = () => {
checkMobile()
updatePositions()
}
onMounted(() => {
checkMobile()
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleResize, true)
updatePositions()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize, true)
})
</script>
<style scoped>
.tutorial-overlay {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.tutorial-backdrop-part {
position: fixed;
background: rgba(0, 0, 0, 0.7);
pointer-events: auto;
transition: all 0.3s ease;
}
.tutorial-backdrop-full {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
pointer-events: auto;
}
.tutorial-highlight-border {
position: fixed;
background: transparent;
border: 4px solid rgba(59, 130, 246, 0.5);
border-radius: 8px;
pointer-events: none;
transition: all 0.3s ease;
z-index: 10000;
}
.tutorial-tooltip {
position: fixed;
z-index: 10001;
pointer-events: auto;
max-width: 480px;
min-width: 320px;
}
/* 移动端样式调整 */
@media (max-width: 767px) {
.tutorial-tooltip {
max-width: calc(100vw - 32px);
min-width: calc(100vw - 32px);
width: calc(100vw - 32px);
}
.tutorial-tooltip-center {
max-width: calc(100vw - 32px);
}
.tutorial-card {
font-size: 0.9rem;
}
.tutorial-highlight-border {
border-width: 2px;
}
}
.tutorial-tooltip-center {
max-width: 560px;
}
@media (max-width: 767px) {
.tutorial-tooltip-center {
max-width: calc(100vw - 32px);
}
}
.tutorial-card {
animation: tutorial-fade-in 0.3s ease;
}
@keyframes tutorial-fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Dark mode adjustments */
.dark .tutorial-backdrop-part {
background: rgba(0, 0, 0, 0.85);
}
.dark .tutorial-backdrop-full {
background: rgba(0, 0, 0, 0.85);
}
.dark .tutorial-highlight-border {
border-color: rgba(59, 130, 246, 0.6);
}
</style>

View File

@@ -6,7 +6,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-2xl',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[60] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-2xl',
props.class
)
"

View File

@@ -4,7 +4,7 @@
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[60] bg-black/80',
props.class
)
"

View File

@@ -6,7 +6,7 @@
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 w-[calc(100vw-3rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 sm:w-auto sm:min-w-[764px] flex flex-col p-0',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[60] w-[calc(100vw-3rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 sm:w-auto sm:min-w-[764px] flex flex-col p-0',
containerClass
)
"

View File

@@ -0,0 +1,93 @@
<template>
<div v-if="totalPages > 1" class="fixed bottom-4 left-1/2 -translate-x-1/2 z-40">
<div class="flex items-center gap-2">
<!-- 上一页按钮 - 圆形胶囊 -->
<button
v-if="currentPage > 1"
@click="emit('update:page', currentPage - 1)"
class="h-10 w-10 rounded-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border shadow-lg flex items-center justify-center hover:bg-accent transition-colors"
>
<ChevronLeft class="h-5 w-5" />
</button>
<!-- 页码 - 椭圆形胶囊 -->
<div class="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border rounded-full py-2 px-3 shadow-lg flex items-center gap-1">
<button
v-for="pageNum in pageNumbers"
:key="pageNum"
@click="emit('update:page', pageNum)"
class="h-8 min-w-8 px-2 rounded-full text-sm font-medium transition-colors"
:class="
pageNum === currentPage
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent'
"
>
{{ pageNum }}
</button>
</div>
<!-- 下一页按钮 - 圆形胶囊 -->
<button
v-if="currentPage < totalPages"
@click="emit('update:page', currentPage + 1)"
class="h-10 w-10 rounded-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border shadow-lg flex items-center justify-center hover:bg-accent transition-colors"
>
<ChevronRight class="h-5 w-5" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
interface Props {
page: number
totalPages: number
maxVisible?: number
}
const props = withDefaults(defineProps<Props>(), {
maxVisible: 3
})
const emit = defineEmits<{
'update:page': [page: number]
}>()
const currentPage = computed(() => props.page)
// 生成页码列表 - 最多显示指定数量页码,不含省略号
const pageNumbers = computed(() => {
const pages: number[] = []
const { totalPages, maxVisible } = props
const current = currentPage.value
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
let start = current - Math.floor(maxVisible / 2)
let end = current + Math.floor(maxVisible / 2)
// 边界调整
if (start < 1) {
start = 1
end = maxVisible
}
if (end > totalPages) {
end = totalPages
start = totalPages - maxVisible + 1
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
}
return pages
})
</script>

View File

@@ -6,3 +6,4 @@ export { default as PaginationItem } from './PaginationItem.vue'
export { default as PaginationLast } from './PaginationLast.vue'
export { default as PaginationNext } from './PaginationNext.vue'
export { default as PaginationPrevious } from './PaginationPrevious.vue'
export { default as FixedPagination } from './FixedPagination.vue'

View File

@@ -86,7 +86,6 @@
import SheetHeader from '@/components/ui/sheet/SheetHeader.vue'
import SheetTitle from '@/components/ui/sheet/SheetTitle.vue'
import { SIDEBAR_WIDTH_MOBILE, useSidebar } from './utils'
import { useTutorial } from '@/composables/useTutorial'
import { useRouter } from 'vue-router'
defineOptions({
@@ -101,47 +100,17 @@
const router = useRouter()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { tutorialState, currentStep, nextStep } = useTutorial()
// 包装setOpenMobile以拦截教程期间的关闭操作
// 处理移动端侧边栏打开/关闭
const handleOpenMobileChange = (open: boolean) => {
// 如果是移动端且在教程的菜单相关步骤,阻止关闭侧边栏
if (tutorialState.value.isActive && currentStep.value) {
// 只在第3步期间阻止关闭侧边栏让玩家必须手动打开
if (currentStep.value.id === 'menu_intro_mobile') {
// 只允许打开,不允许关闭
if (open) {
setOpenMobile(true)
}
// 如果试图关闭,忽略该操作,保持打开状态
return
}
}
// 其他情况正常更新
setOpenMobile(open)
}
// 监听openMobile变化在移动端教程第3步时侧边栏打开后自动推进到第4步
watch(
() => openMobile.value,
(isOpen) => {
if (isMobile.value && tutorialState.value.isActive && currentStep.value) {
// 如果在第3步且侧边栏刚打开,自动推进到第4步
if (currentStep.value.id === 'menu_intro_mobile' && isOpen) {
setTimeout(() => {
nextStep()
}, 300) // 延迟300ms让侧边栏动画完成
}
}
}
)
// 监听路由变化,在移动端关闭侧边栏
watch(
() => router.currentRoute.value.path,
() => {
if (isMobile.value && openMobile.value) {
// 路由变化时关闭移动端侧边栏
setOpenMobile(false)
}
}

View File

@@ -16,18 +16,11 @@
<script setup lang="ts">
import type { HTMLAttributes, Ref } from 'vue'
import { defaultDocument, useEventListener, useMediaQuery, useVModel } from '@vueuse/core'
import { useMediaQuery, useVModel } from '@vueuse/core'
import { TooltipProvider } from 'reka-ui'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
import {
provideSidebarContext,
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_KEYBOARD_SHORTCUT,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON
} from './utils'
import { provideSidebarContext, SIDEBAR_WIDTH, SIDEBAR_WIDTH_ICON } from './utils'
const props = withDefaults(
defineProps<{
@@ -36,7 +29,7 @@
class?: HTMLAttributes['class']
}>(),
{
defaultOpen: !defaultDocument?.cookie.includes(`${SIDEBAR_COOKIE_NAME}=false`),
defaultOpen: true,
open: undefined
}
)
@@ -54,30 +47,17 @@
}) as Ref<boolean>
const setOpen = (value: boolean) => {
open.value = value // emits('update:open', value)
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open.value}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
open.value = value
}
const setOpenMobile = (value: boolean) => {
openMobile.value = value
}
// Helper to toggle the sidebar.
const toggleSidebar = () => {
return isMobile.value ? setOpenMobile(!openMobile.value) : setOpen(!open.value)
}
useEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
})
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = computed(() => (open.value ? 'expanded' : 'collapsed'))
provideSidebarContext({

View File

@@ -1,12 +1,9 @@
import type { ComputedRef, Ref } from 'vue'
import { createContext } from 'reka-ui'
export const SIDEBAR_COOKIE_NAME = 'sidebar_state'
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
export const SIDEBAR_WIDTH = '16rem'
export const SIDEBAR_WIDTH_MOBILE = '18rem'
export const SIDEBAR_WIDTH_ICON = '3rem'
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
export const [useSidebar, provideSidebarContext] = createContext<{
state: ComputedRef<'expanded' | 'collapsed'>