Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions pr_body.txt
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
### AI: Resolves #118
### AI: Resolves #120

This Pull Request was automatically generated by OpenCode to address Issue #118.
This Pull Request was automatically generated by OpenCode to address Issue #120.

### 📝 AI Modification Summary & Conclusion:
# Fix: Prevent Premature `checkLogin()` Dialog on Component Activation
# Implementation Summary: Offline / Poor Network Alert Popup

## Files Modified

### 1. `src/views/Friends.vue` (lines 50–62)
- **Import change**: Added `nextTick` to the `from 'vue'` import.
- **Before**: `onActivated(checkLogin)` — passed the function reference directly, executing synchronously on every activation.
- **After**: Wrapped in an async arrow function that calls `await nextTick()` first, then reads `storageManager.getObj('userInfo').value?.Nickname`. If the nickname is truthy (user is already logged in), it returns early without calling `checkLogin()`. Only when nickname is still null does it proceed to `checkLogin()`.

### 2. `src/views/Notifications.vue` (lines 53–63)
- **Import change**: Added `nextTick` to the `from 'vue'` import.
- **Before**: `onActivated(() => { clearNotificationUnread(); checkLogin(); })`.
- **After**: The callback is now `async`. It runs `clearNotificationUnread()` immediately, then `await nextTick()` before checking the nickname guard. If the nickname exists, `checkLogin()` is skipped; otherwise it proceeds.
## Objective
Show a popup banner to alert users when they are offline or have a poor network connection, inform them that the application is reading data from cache, and support i18n for all messages.

## Technical Solution

- **`await nextTick()`** ensures Vue's current rendering cycle and any internal reactivity/state initializations have settled before we attempt to read login state. This prevents the scenario where `onActivated` fires before the state layer has finished hydrating.
- **Early-return guard** (`if (nickname) return`): if `userInfo.Nickname` is already present in `localStorage` (read via `storageManager.getObj('userInfo')`), the login dialog is suppressed because the user is confirmed logged in.
- **Only calls `checkLogin()` when data is genuinely absent**, either because the user is not logged in or because the state truly hasn't been initialized yet. In either case, `checkLogin()` will show the dialog only when `showLoginLeader` is true (default) and `Nickname` is null.

## Summary
## Files Modified

The root cause was that `onActivated` fired `checkLogin()` synchronously during the component's activation lifecycle hook. If the user state (stored in `localStorage` and accessed via the custom `storageManager`) had not yet been fully read/initialized, `Nickname` would be `null`, causing `checkLogin()` to display a blocking login dialog overlay. By deferring the check with `await nextTick()` and adding a guard for already-present user data, the dialog is no longer shown prematurely.
### 1. `src/i18n/en.ts`, `src/i18n/zh.ts`, `src/i18n/de.ts`, `src/i18n/fr.ts`, `src/i18n/ja.ts`
- Added a new `network` translation section in each language file with three keys:
- `offline` — "You are currently offline" (translated per locale)
- `poorConnection` — "Your network connection is unstable" (translated per locale)
- `usingCache` — "Reading data from cache" (translated per locale)

### 2. `src/services/useNetworkStatus.ts` (new file)
- Created a Vue 3 Composition API composable that monitors:
- `navigator.onLine` + browser `online`/`offline` events for binary connectivity
- Network Information API (`navigator.connection`) for connection quality (`effectiveType` and `saveData`)
- Returns two reactive refs:
- `isOnline` — `true`/`false` reflecting browser online status
- `isPoorConnection` — `true` when `effectiveType` is `'slow-2g'` or `'2g'`, or `saveData` is enabled
- Properly registers and cleans up event listeners via `onMounted`/`onUnmounted`

### 3. `src/components/utils/NetworkStatusBanner.vue` (new file)
- A fixed-position banner component displayed at the top of the viewport
- Uses Vue `<Transition>` for smooth slide-in/out animation
- Two visual states:
- `--offline` (red background, white text) when the user is completely disconnected
- `--poor` (yellow background, dark text) when the connection is slow/unstable
- Shows an icon, a status message (`network.offline` or `network.poorConnection`), and a secondary cache notice (`network.usingCache`)
- Uses `useI18n()` for translated messages
- Accessible with `role="alert"` and `aria-live="assertive"`

### 4. `src/App.vue`
- Imported and rendered `<NetworkStatusBanner>` before `<CookieNotice>` at the top of the app
- Imported `useNetworkStatus` composable and passed its reactive state (`isOnline`, `isPoorConnection`) as props to the banner

## Technical Decisions
- **Composables pattern** — Followed the existing Vue 3 Composition API style used throughout the project
- **No external dependencies** — Relies only on browser-native APIs (`navigator.onLine`, `Network Information API`) and Vue built-ins
- **i18n-first** — All user-facing strings are defined in the `network` translation section and accessed via `useI18n()` `t()` function — consistent with the existing i18n architecture
- **Banner vs Notification** — Chose a top-of-page fixed banner (over Naive UI corner notifications) for maximum visibility and persistence
- **Graceful degradation** — If the Network Information API is unavailable, the composable gracefully falls back to only tracking online/offline events
- **Component pattern** — Follows the existing `CookieNotice.vue` component structure (reactive visibility, scoped CSS, flex layout) for consistency
4 changes: 4 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<div id="app" @click="handleClick">
<NetworkStatusBanner :is-online="isOnline" :is-poor-connection="isPoorConnection" />
<CookieNotice />
<router-view v-slot="{ Component }">
<!-- keep alive源自于vue-router的缓存 -->
Expand All @@ -15,6 +16,9 @@
<script setup lang="ts">
import showUserCard from '@popup/userProfileDialog.ts'
import CookieNotice from './components/utils/CookieNotice.vue'
import NetworkStatusBanner from './components/utils/NetworkStatusBanner.vue'
import { useNetworkStatus } from './services/useNetworkStatus'
const { isOnline, isPoorConnection } = useNetworkStatus()
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement
if (target.classList.contains('RUser')) {
Expand Down
96 changes: 96 additions & 0 deletions src/components/utils/NetworkStatusBanner.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<template>
<Transition name="network-banner">
<aside
v-if="isOnline === false || (isOnline && isPoorConnection)"
class="network-banner"
:class="{
'network-banner--offline': isOnline === false,
'network-banner--poor': isOnline && isPoorConnection,
}"
role="alert"
aria-live="assertive"
>
<span class="network-banner__icon">
{{ isOnline === false ? '🔴' : '⚠️' }}
</span>
<span class="network-banner__text">
{{ isOnline === false ? t('network.offline') : t('network.poorConnection') }}
</span>
<span class="network-banner__cache">{{ t('network.usingCache') }}</span>
</aside>
</Transition>
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'

defineProps<{
isOnline: boolean | null
isPoorConnection: boolean
}>()

const { t } = useI18n()
</script>

<style scoped>
.network-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
font-size: 0.9rem;
line-height: 1.4;
pointer-events: auto;
flex-wrap: wrap;
}

.network-banner--offline {
color: #fff;
background: #e74c3c;
}

.network-banner--poor {
color: #664d03;
background: #fff3cd;
}

.network-banner__icon {
flex-shrink: 0;
font-size: 1rem;
}

.network-banner__text {
font-weight: 600;
white-space: nowrap;
}

.network-banner__cache {
opacity: 0.85;
font-size: 0.85rem;
}

.network-banner--offline .network-banner__cache {
color: rgba(255, 255, 255, 0.85);
}

.network-banner--poor .network-banner__cache {
color: rgba(102, 77, 3, 0.75);
}

.network-banner-enter-active,
.network-banner-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}

.network-banner-enter-from,
.network-banner-leave-to {
transform: translateY(-100%);
opacity: 0;
}
</style>
5 changes: 5 additions & 0 deletions src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ export default {
friends: 'Freunde',
notifications: 'Benachrichtigungen',
},
network: {
offline: 'Sie sind derzeit offline',
poorConnection: 'Ihre Netzwerkverbindung ist instabil',
usingCache: 'Daten werden aus dem Cache gelesen',
},
ui: {
messages: {
loading: 'Wird geladen...',
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export default {
friends: 'Friends',
notifications: 'Notifications',
},
network: {
offline: 'You are currently offline',
poorConnection: 'Your network connection is unstable',
usingCache: 'Reading data from cache',
},
ui: {
messages: {
loading: 'Loading...',
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ export default {
friends: 'Amis',
notifications: 'Notifications',
},
network: {
offline: 'Vous êtes actuellement hors ligne',
poorConnection: 'Votre connexion réseau est instable',
usingCache: 'Lecture des données depuis le cache',
},
ui: {
messages: {
loading: 'Chargement...',
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ export default {
friends: '友人',
notifications: '通知',
},
network: {
offline: '現在オフラインです',
poorConnection: 'ネットワーク接続が不安定です',
usingCache: 'キャッシュからデータを読み取っています',
},
ui: {
messages: {
loading: '読み込み中...',
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ export default {
friends: '好友',
notifications: '通知',
},
network: {
offline: '您当前处于离线状态',
poorConnection: '您的网络连接不稳定',
usingCache: '正在从缓存读取数据',
},
ui: {
messages: {
loading: '加载中...',
Expand Down
62 changes: 62 additions & 0 deletions src/services/useNetworkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ref, onMounted, onUnmounted } from 'vue'

interface NetworkInformation extends EventTarget {
effectiveType?: string
saveData?: boolean
addEventListener: (type: string, listener: EventListener) => void
removeEventListener: (type: string, listener: EventListener) => void
}

function getConnection(): NetworkInformation | null {
const nav = navigator as Navigator & { connection?: NetworkInformation; mozConnection?: NetworkInformation; webkitConnection?: NetworkInformation }
return nav.connection || nav.mozConnection || nav.webkitConnection || null
}

export function useNetworkStatus() {
const isOnline = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
const isPoorConnection = ref(false)

let connection: NetworkInformation | null = null

function updateConnectionQuality() {
if (!connection) return
const type = connection.effectiveType
isPoorConnection.value = type === 'slow-2g' || type === '2g' || !!connection.saveData
}

function onOnline() {
isOnline.value = true
updateConnectionQuality()
}

function onOffline() {
isOnline.value = false
isPoorConnection.value = false
}

function onConnectionChange() {
updateConnectionQuality()
}

onMounted(() => {
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)

connection = getConnection()
if (connection) {
connection.addEventListener('change', onConnectionChange)
updateConnectionQuality()
}
})

onUnmounted(() => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)

if (connection) {
connection.removeEventListener('change', onConnectionChange)
}
})

return { isOnline, isPoorConnection }
}
Loading