feat: 添加了浏览器通知和页面内通知

暂包含建造完成和科研完成
This commit is contained in:
StarsEnd
2025-12-18 01:34:29 +08:00
parent 2e3ac1231f
commit e8590d54c7
10 changed files with 327 additions and 11 deletions

View File

@@ -438,7 +438,7 @@
import UpdateDialog from '@/components/UpdateDialog.vue'
import TutorialOverlay from '@/components/TutorialOverlay.vue'
import Sonner from '@/components/ui/sonner/Sonner.vue'
import { MissionType, BuildingType, DiplomaticEventType } from '@/types/game'
import { MissionType, BuildingType, TechnologyType, DiplomaticEventType } from '@/types/game'
import type { FleetMission, NPC, IncomingFleetAlert, MissileAttack } from '@/types/game'
import { DIPLOMATIC_CONFIG } from '@/config/gameConfig'
import type { VersionInfo } from '@/utils/versionCheck'
@@ -493,7 +493,7 @@
const npcStore = useNPCStore()
const { isDark } = useTheme()
const { t } = useI18n()
const { BUILDINGS } = useGameConfig()
const { BUILDINGS, TECHNOLOGIES } = useGameConfig()
const { startTutorial, tutorialState, currentStep } = useTutorial()
// ConfirmDialog 状态
@@ -507,6 +507,50 @@
const showUpdateDialog = ref(false)
const updateInfo = ref<VersionInfo | null>(null)
const handleNotification = (type: string, itemType: string, level?: number) => {
const settings = gameStore.player.notificationSettings
if (!settings) return
// 检查主开关
if (!settings.browser && !settings.inApp) return
// 检查具体类型开关
let typeKey = ''
let title = ''
let body = ''
if (type === 'building') {
typeKey = 'construction'
const buildingType = itemType as BuildingType
const name = BUILDINGS.value[buildingType]?.name || itemType
title = t('notifications.constructionComplete')
body = `${name} Lv ${level}`
} else if (type === 'technology') {
typeKey = 'research'
const technologyType = itemType as TechnologyType
const name = TECHNOLOGIES.value[technologyType]?.name || itemType
title = t('notifications.researchComplete')
body = `${name} Lv ${level}`
} else {
return
}
if (!settings.types[typeKey]) return
// browser
if (settings.browser && 'Notification' in window && Notification.permission === 'granted') {
const shouldSuppress = settings.suppressInFocus && document.hasFocus()
if (!shouldSuppress) {
new Notification(title, { body, icon: '/favicon.ico' })
}
}
// toast
if (settings.inApp) {
toast.success(title, { description: body })
}
}
const handleConfirmDialogConfirm = () => {
if (confirmDialogAction.value) {
confirmDialogAction.value()
@@ -567,7 +611,7 @@
// 检查军官过期
gameLogic.checkOfficersExpiration(gameStore.player.officers, now)
// 处理游戏更新(建造队列、研究队列等)
const result = gameLogic.processGameUpdate(gameStore.player, now, gameStore.gameSpeed)
const result = gameLogic.processGameUpdate(gameStore.player, now, gameStore.gameSpeed, handleNotification)
gameStore.player.researchQueue = result.updatedResearchQueue
// 处理舰队任务
gameStore.player.fleetMissions.forEach(mission => {

View File

@@ -0,0 +1,48 @@
<template>
<button
type="button"
role="switch"
:aria-checked="checked"
:data-state="checked ? 'checked' : 'unchecked'"
:disabled="disabled"
:class="
cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-input',
props.class
)
"
@click="toggle"
>
<span
:data-state="checked ? 'checked' : 'unchecked'"
:class="
cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform',
checked ? 'translate-x-5' : 'translate-x-0'
)
"
/>
</button>
</template>
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { type HTMLAttributes } from 'vue'
const props = defineProps<{
checked?: boolean
disabled?: boolean
class?: HTMLAttributes['class']
}>()
const emit = defineEmits<{
(e: 'update:checked', value: boolean): void
}>()
const toggle = () => {
if (props.disabled) return
emit('update:checked', !props.checked)
}
</script>

View File

@@ -0,0 +1,2 @@
export { default as Switch } from './Switch.vue'

View File

@@ -779,7 +779,26 @@ export default {
buildDate: '构建日期',
community: '社区',
github: 'GitHub 仓库',
qqGroup: 'QQ 交流群'
qqGroup: 'QQ 交流群',
notifications: '通知设置',
notificationsDesc: '管理游戏内的通知提醒',
notificationTypes: '通知类型',
browserNotifications: '浏览器通知',
inAppNotifications: '页面内通知',
constructionComplete: '建筑完成',
researchComplete: '研究完成',
browserPermission: '启用浏览器通知',
permissionGranted: '已获得权限',
permissionDenied: '权限被拒绝/未获得',
inAppNotificationsDesc: '通过页面弹窗显示',
notificationsDisabled: '启用上方任一开关以配置具体通知',
suppressInFocus: '页面聚焦时不发送浏览器通知',
expandTypes: '展开详细设置',
collapseTypes: '收起详细设置'
},
notifications: {
constructionComplete: '建造完成',
researchComplete: '研究完成'
},
gmView: {
title: 'GM 管理面板',

View File

@@ -122,7 +122,8 @@ export const createBuildQueueItem = (buildingType: BuildingType, targetLevel: nu
export const completeBuildQueue = (
planet: Planet,
now: number,
onPointsEarned?: (points: number, type: 'building' | 'ship' | 'defense', itemType: string, level?: number, quantity?: number) => void
onPointsEarned?: (points: number, type: 'building' | 'ship' | 'defense', itemType: string, level?: number, quantity?: number) => void,
onCompleted?: (type: 'building' | 'ship' | 'defense' | 'demolish', itemType: string, level?: number, quantity?: number) => void
): void => {
planet.buildQueue = planet.buildQueue.filter(item => {
if (now >= item.endTime) {
@@ -137,6 +138,10 @@ export const completeBuildQueue = (
const points = pointsLogic.calculateBuildingPoints(item.itemType as BuildingType, oldLevel, newLevel)
onPointsEarned(points, 'building', item.itemType, newLevel)
}
if (onCompleted) {
onCompleted('building', item.itemType, newLevel)
}
} else if (item.type === 'ship') {
const shipType = item.itemType as ShipType
const quantity = item.quantity || 0

View File

@@ -101,7 +101,8 @@ export const generatePositionKey = (galaxy: number, system: number, position: nu
export const processGameUpdate = (
player: Player,
now: number,
gameSpeed: number = 1
gameSpeed: number = 1,
onNotification?: (type: string, itemType: string, level?: number) => void
): {
updatedResearchQueue: BuildQueueItem[]
} => {
@@ -113,6 +114,13 @@ export const processGameUpdate = (
pointsLogic.addPoints(player, points)
}
// 通知回调
const onCompleted = (type: string, itemType: string, level?: number, _quantity?: number) => {
if (onNotification) {
onNotification(type, itemType, level)
}
}
// 更新所有星球资源(直接同步计算,避免 Worker 通信开销)
player.planets.forEach(planet => {
resourceLogic.updatePlanetResources(planet, now, bonuses, gameSpeed)
@@ -121,7 +129,7 @@ export const processGameUpdate = (
// 更新所有星球其他状态
player.planets.forEach(planet => {
// 检查建造队列
buildingLogic.completeBuildQueue(planet, now, onPointsEarned)
buildingLogic.completeBuildQueue(planet, now, onPointsEarned, onCompleted)
// 更新星球最大空间
if (planet.isMoon) {
@@ -133,7 +141,13 @@ export const processGameUpdate = (
})
// 检查研究队列
const updatedResearchQueue = researchLogic.completeResearchQueue(player.researchQueue, player.technologies, now, onPointsEarned)
const updatedResearchQueue = researchLogic.completeResearchQueue(
player.researchQueue,
player.technologies,
now,
onPointsEarned,
onCompleted
)
return {
updatedResearchQueue

View File

@@ -98,7 +98,8 @@ export const completeResearchQueue = (
researchQueue: BuildQueueItem[],
technologies: Partial<Record<TechnologyType, number>>,
now: number,
onPointsEarned?: (points: number, type: 'technology', itemType: string, level: number) => void
onPointsEarned?: (points: number, type: 'technology', itemType: string, level: number) => void,
onCompleted?: (type: 'technology', itemType: string, level: number) => void
): BuildQueueItem[] => {
return researchQueue.filter(item => {
if (now >= item.endTime) {
@@ -112,6 +113,12 @@ export const completeResearchQueue = (
const points = pointsLogic.calculateTechnologyPoints(item.itemType as TechnologyType, oldLevel, newLevel)
onPointsEarned(points, 'technology', item.itemType, newLevel)
}
// 通知完成
if (onCompleted) {
onCompleted('technology', item.itemType, newLevel)
}
return false
}
return true

View File

@@ -41,12 +41,31 @@ export const useGameStore = defineStore('game', {
giftRejectedNotifications: [],
points: 0,
isGMEnabled: false, // 明确设置 GM 模式默认为 false
lastVersionCheckTime: 0 // 最后一次检查版本的时间戳默认为0
lastVersionCheckTime: 0, // 最后一次检查版本的时间戳默认为0
notificationSettings: {
browser: false,
inApp: true,
suppressInFocus: false,
types: {
construction: true,
research: true
}
}
} as Player,
currentPlanetId: '',
isDark: '',
locale: 'zh-CN' as Locale
}),
actions: {
async requestBrowserPermission(): Promise<boolean> {
if (!('Notification' in window)) return false
if (Notification.permission === 'granted') return true
const permission = await Notification.requestPermission()
return permission === 'granted'
}
},
getters: {
currentPlanet(): Planet | undefined {
return this.player.planets.find(p => p.id === this.currentPlanetId)

View File

@@ -574,6 +574,19 @@ export interface Player {
diplomaticReports?: DiplomaticReport[] // 外交变化报告
// 新手引导字段
tutorialProgress?: TutorialProgress // 新手引导进度
// 通知设置
notificationSettings?: NotificationSettings
}
export interface NotificationSettings {
browser: boolean
inApp: boolean
suppressInFocus: boolean // 当页面聚焦时是否浏览器通知
types: {
construction: boolean
research: boolean
[key: string]: boolean
}
}
// 游戏状态

View File

@@ -89,6 +89,85 @@
</CardContent>
</Card>
<!-- 通知设置 -->
<Card>
<CardHeader>
<CardTitle>{{ t('settings.notifications') }}</CardTitle>
<CardDescription>{{ t('settings.notificationsDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- 浏览器通知 -->
<div class="flex flex-col gap-4 p-4 border rounded-lg">
<div class="flex items-center justify-between">
<div class="space-y-1">
<h3 class="font-medium">{{ t('settings.browserNotifications') }}</h3>
<p class="text-sm text-muted-foreground">{{ t('settings.browserPermission') }}</p>
</div>
<Switch :checked="gameStore.player.notificationSettings?.browser" @update:checked="handleBrowserSwitch" />
</div>
<!-- 页面聚焦时不发送 -->
<div class="flex items-center justify-between pl-4 border-l-2" :class="{ 'opacity-50 pointer-events-none': !gameStore.player.notificationSettings?.browser }">
<Label class="font-normal">{{ t('settings.suppressInFocus') }}</Label>
<Switch
:checked="gameStore.player.notificationSettings?.suppressInFocus"
@update:checked="updateSuppressSetting"
:disabled="!gameStore.player.notificationSettings?.browser"
/>
</div>
</div>
<!-- 页面内通知 -->
<div class="flex items-center justify-between p-4 border rounded-lg">
<div class="space-y-1">
<h3 class="font-medium">{{ t('settings.inAppNotifications') }}</h3>
<p class="text-sm text-muted-foreground">{{ t('settings.inAppNotificationsDesc') || t('settings.inAppNotifications') }}</p>
</div>
<Switch
:checked="gameStore.player.notificationSettings?.inApp"
@update:checked="(val: boolean) => updateInAppSetting(val)"
/>
</div>
<!-- 具体通知类型 -->
<div class="border rounded-lg overflow-hidden" :class="{ 'opacity-50 pointer-events-none': areMainSwitchesOff }">
<div
class="flex items-center justify-between p-4 bg-muted/50 cursor-pointer select-none"
@click="!areMainSwitchesOff && (isTypesExpanded = !isTypesExpanded)"
>
<div class="space-y-1">
<h3 class="font-medium">{{ t('settings.notificationTypes') }}</h3>
<p class="text-sm text-muted-foreground">
{{ areMainSwitchesOff ? t('settings.notificationsDisabled') : (isTypesExpanded ? t('settings.collapseTypes') : t('settings.expandTypes')) }}
</p>
</div>
<Button variant="ghost" size="sm" class="h-8 w-8 p-0">
<component :is="isTypesExpanded ? ChevronUp : ChevronDown" class="h-4 w-4" />
</Button>
</div>
<div v-if="isTypesExpanded && !areMainSwitchesOff" class="p-4 space-y-4 border-t bg-card">
<!-- 建造完成 -->
<div class="flex items-center justify-between">
<Label class="font-normal cursor-pointer" @click="toggleType('construction')">{{ t('settings.constructionComplete') }}</Label>
<Switch
:checked="gameStore.player.notificationSettings?.types.construction"
@update:checked="(val: boolean) => updateTypeSetting('construction', val)"
/>
</div>
<!-- 研究完成 -->
<div class="flex items-center justify-between">
<Label class="font-normal cursor-pointer" @click="toggleType('research')">{{ t('settings.researchComplete') }}</Label>
<Switch
:checked="gameStore.player.notificationSettings?.types.research"
@update:checked="(val: boolean) => updateTypeSetting('research', val)"
/>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- 关于 -->
<Card>
<CardHeader>
@@ -164,6 +243,8 @@
import { useI18n } from '@/composables/useI18n'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
AlertDialog,
AlertDialogAction,
@@ -174,7 +255,7 @@
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { Download, Upload, Trash2, ExternalLink, MessagesSquare, Play, Pause, RefreshCw } from 'lucide-vue-next'
import { Download, Upload, Trash2, ExternalLink, MessagesSquare, Play, Pause, RefreshCw, ChevronDown, ChevronUp } from 'lucide-vue-next'
import { saveAs } from 'file-saver'
import { toast } from 'vue-sonner'
import pkg from '../../package.json'
@@ -195,6 +276,70 @@
const confirmMessage = ref('')
let confirmCallback: (() => void) | null = null
const isTypesExpanded = ref(false)
// Ensure notification settings exist
if (!gameStore.player.notificationSettings) {
gameStore.player.notificationSettings = {
browser: false,
inApp: true,
suppressInFocus: false,
types: { construction: true, research: true }
}
}
const areMainSwitchesOff = computed(() => {
const s = gameStore.player.notificationSettings
return !s?.browser && !s?.inApp
})
// Auto-collapse if main switches are off
// watch(areMainSwitchesOff, (val) => {
// if (val) isTypesExpanded.value = false
// })
const updateInAppSetting = (val: boolean) => {
if (gameStore.player.notificationSettings) {
gameStore.player.notificationSettings.inApp = val
}
}
const updateSuppressSetting = (val: boolean) => {
if (gameStore.player.notificationSettings) {
gameStore.player.notificationSettings.suppressInFocus = val
}
}
const updateTypeSetting = (key: string, val: boolean) => {
if (gameStore.player.notificationSettings) {
gameStore.player.notificationSettings.types[key] = val
}
}
const toggleType = (key: string) => {
if (gameStore.player.notificationSettings) {
const current = gameStore.player.notificationSettings.types[key]
gameStore.player.notificationSettings.types[key] = !current
}
}
const handleBrowserSwitch = async (checked: boolean) => {
if (!gameStore.player.notificationSettings) return
if (checked) {
const granted = await gameStore.requestBrowserPermission()
if (granted) {
gameStore.player.notificationSettings.browser = true
toast.success(t('settings.permissionGranted'))
} else {
gameStore.player.notificationSettings.browser = false
toast.error(t('settings.permissionDenied'))
}
} else {
gameStore.player.notificationSettings.browser = false
}
}
// 计算是否可以检查版本主动检测5分钟内不能重复检查
const canCheck = computed(() => canCheckVersion(gameStore.player.lastManualUpdateCheck || 0))