mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(about): 应用内查看更新日志并与下载进度同窗
- 关于页提供「查看更新日志」入口,弹窗可滚动阅读 Markdown - 下载进度与更新日志合并,支持边看日志边下载 - 本地缓存已读版本并展示未读提示 - 修正弹窗层级,避免被设置中心遮挡 - 补充多语言文案与单测
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import Modal from './components/common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip, Alert } from 'antd';
|
||||
import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Switch, Input, InputNumber, Select, Segmented, Tooltip, Alert } from 'antd';
|
||||
import { UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined, TableOutlined, MenuOutlined, MenuFoldOutlined, MenuUnfoldOutlined, PoweroffOutlined, TagOutlined, UserOutlined, UpCircleOutlined, MessageOutlined, FileTextOutlined, SyncOutlined, SendOutlined, AuditOutlined } from '@ant-design/icons';
|
||||
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
@@ -16,6 +16,12 @@ import NativeDetachedWindowController from './components/NativeDetachedWindowCon
|
||||
import ConnectionModal from './components/ConnectionModal';
|
||||
import SnippetSettingsModal from './components/SnippetSettingsModal';
|
||||
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
|
||||
import UpdateReleaseNotesModal from './components/UpdateReleaseNotesModal';
|
||||
import {
|
||||
buildReleaseNotesReadKey,
|
||||
isReleaseNotesRead,
|
||||
markReleaseNotesRead,
|
||||
} from './utils/updateReleaseNotesReadState';
|
||||
import { type DataSyncEntryMode } from './components/dataSyncEntryMode';
|
||||
import DriverManagerModal from './components/DriverManagerModal';
|
||||
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
|
||||
@@ -2338,6 +2344,8 @@ function App() {
|
||||
updateCenterBridgeRef,
|
||||
});
|
||||
const [aboutLastCheckedAt, setAboutLastCheckedAt] = useState('');
|
||||
const [releaseNotesModalOpen, setReleaseNotesModalOpen] = useState(false);
|
||||
const [releaseNotesReadTick, setReleaseNotesReadTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!lastUpdateInfo) {
|
||||
return;
|
||||
@@ -2350,6 +2358,47 @@ function App() {
|
||||
lastUpdateInfo?.latestVersion,
|
||||
]);
|
||||
|
||||
const releaseNotesReadKey = useMemo(
|
||||
() => buildReleaseNotesReadKey(lastUpdateInfo),
|
||||
[lastUpdateInfo?.channel, lastUpdateInfo?.latestVersion],
|
||||
);
|
||||
// releaseNotesReadTick 强制在 mark 后重算未读态
|
||||
const hasUnreadReleaseNotes = useMemo(() => {
|
||||
void releaseNotesReadTick;
|
||||
if (!lastUpdateInfo || !releaseNotesReadKey) return false;
|
||||
// 有正文或至少有 GitHub 链接时,未读才有提示意义
|
||||
if (!String(lastUpdateInfo.releaseNotes || '').trim() && !String(lastUpdateInfo.releaseNotesUrl || '').trim()) {
|
||||
return false;
|
||||
}
|
||||
return !isReleaseNotesRead(releaseNotesReadKey);
|
||||
}, [lastUpdateInfo, releaseNotesReadKey, releaseNotesReadTick]);
|
||||
|
||||
const openReleaseNotesModal = useCallback(() => {
|
||||
if (!lastUpdateInfo) return;
|
||||
setReleaseNotesModalOpen(true);
|
||||
}, [lastUpdateInfo]);
|
||||
|
||||
const closeReleaseNotesModal = useCallback(() => {
|
||||
setReleaseNotesModalOpen(false);
|
||||
hideUpdateDownloadProgress();
|
||||
}, [hideUpdateDownloadProgress]);
|
||||
|
||||
const handleReleaseNotesModalOpen = useCallback(() => {
|
||||
if (!releaseNotesReadKey) return;
|
||||
if (markReleaseNotesRead(releaseNotesReadKey)) {
|
||||
setReleaseNotesReadTick((value) => value + 1);
|
||||
}
|
||||
}, [releaseNotesReadKey]);
|
||||
|
||||
/** 下载与更新日志同窗:点下载即打开弹窗并开始下载 */
|
||||
const handleDownloadUpdateWithNotes = useCallback(() => {
|
||||
if (!lastUpdateInfo) return;
|
||||
setReleaseNotesModalOpen(true);
|
||||
void downloadUpdate(lastUpdateInfo, false);
|
||||
}, [downloadUpdate, lastUpdateInfo]);
|
||||
|
||||
const releaseNotesModalVisible = releaseNotesModalOpen || updateDownloadProgress.open;
|
||||
|
||||
const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record<string, unknown> = {}) => {
|
||||
if (!macWindowDiagnosticsEnabled) {
|
||||
return;
|
||||
@@ -5175,6 +5224,32 @@ function App() {
|
||||
: (lastUpdateInfo?.packageType === 'portable'
|
||||
? t('app.about.action.download_portable_update')
|
||||
: t('app.about.action.download_update'));
|
||||
const renderReleaseNotesActionButton = (key = 'release-notes') => (
|
||||
lastUpdateInfo ? (
|
||||
<Button
|
||||
key={key}
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={openReleaseNotesModal}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{t('app.about.release_notes.action.view')}
|
||||
{hasUnreadReleaseNotes ? (
|
||||
<span
|
||||
aria-label={t('app.about.release_notes.unread_badge')}
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: darkMode ? '#4ade80' : '#16a34a',
|
||||
boxShadow: darkMode ? '0 0 0 2px rgba(15,23,42,0.35)' : '0 0 0 2px rgba(255,255,255,0.9)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
) : null
|
||||
);
|
||||
|
||||
const renderAboutUpdateActions = (closeAction?: React.ReactNode) => [
|
||||
isBackgroundProgressForLatestUpdate && !isLatestUpdateDownloaded ? (
|
||||
<Button key="progress" icon={<DownloadOutlined />} onClick={showUpdateDownloadProgress}>{t('app.about.action.download_progress')}</Button>
|
||||
@@ -5182,6 +5257,7 @@ function App() {
|
||||
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
|
||||
<Button key="mute" onClick={muteLatestUpdate}>{t('app.about.action.mute_this_version')}</Button>
|
||||
) : null,
|
||||
renderReleaseNotesActionButton(),
|
||||
<Button
|
||||
key="check"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
@@ -5192,7 +5268,7 @@ function App() {
|
||||
</Button>,
|
||||
closeAction ?? null,
|
||||
lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? (
|
||||
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={() => downloadUpdate(lastUpdateInfo, false)}>{updateDownloadActionLabel}</Button>
|
||||
<Button key="download" type="primary" icon={<DownloadOutlined />} onClick={handleDownloadUpdateWithNotes}>{updateDownloadActionLabel}</Button>
|
||||
) : null,
|
||||
isLatestUpdateDownloaded ? (
|
||||
<Button key="open-install-directory" onClick={openDownloadedUpdateDirectory}>
|
||||
@@ -5350,23 +5426,49 @@ function App() {
|
||||
? t('app.about.hero.update_available_version', { version: latestVersionText })
|
||||
: (lastUpdateInfo ? t('app.about.hero.no_update') : t('app.about.update_status.not_checked'));
|
||||
const currentVersionText = lastUpdateInfo?.currentVersion || aboutDisplayVersion;
|
||||
const releaseNotesText = lastUpdateInfo?.releaseName || (hasUpdate ? t('app.about.release_notes.fallback') : '-');
|
||||
const releaseTimeText = formatAboutReleaseTime(lastUpdateInfo?.releasePublishedAt);
|
||||
const canOpenReleaseNotes = Boolean(lastUpdateInfo);
|
||||
const packageType = ['portable', 'msi', 'dmg', 'archive'].includes(String(lastUpdateInfo?.packageType || ''))
|
||||
? String(lastUpdateInfo?.packageType)
|
||||
: 'unknown';
|
||||
const mutedText = utilityMutedTextStyle.color;
|
||||
const dividerColor = darkMode ? 'rgba(255,255,255,0.09)' : 'rgba(16,24,40,0.09)';
|
||||
const versionRows = [
|
||||
const versionRows: Array<[string, React.ReactNode]> = [
|
||||
[t('app.about.version.current'), currentVersionText],
|
||||
[t('app.about.version.latest'), latestVersionText],
|
||||
[t('app.about.version.release_time'), releaseTimeText],
|
||||
[t('app.about.version.release_notes'), releaseNotesText],
|
||||
[
|
||||
t('app.about.version.release_notes'),
|
||||
(
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={!canOpenReleaseNotes}
|
||||
onClick={openReleaseNotesModal}
|
||||
style={{ padding: 0, height: 'auto', fontWeight: 600 }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{t('app.about.release_notes.action.view')}
|
||||
{hasUnreadReleaseNotes ? (
|
||||
<span
|
||||
aria-label={t('app.about.release_notes.unread_badge')}
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: darkMode ? '#4ade80' : '#16a34a',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
),
|
||||
],
|
||||
...(installMode === 'msi' || installMode === 'portable'
|
||||
? [[t('app.about.version.install_mode'), t(`app.about.install_mode.${installMode}`)]]
|
||||
? [[t('app.about.version.install_mode'), t(`app.about.install_mode.${installMode}`)] as [string, React.ReactNode]]
|
||||
: []),
|
||||
...(hasUpdate && packageType !== 'unknown'
|
||||
? [[t('app.about.version.package_type'), t(`app.about.package_type.${packageType}`)]]
|
||||
? [[t('app.about.version.package_type'), t(`app.about.package_type.${packageType}`)] as [string, React.ReactNode]]
|
||||
: []),
|
||||
];
|
||||
|
||||
@@ -7504,9 +7606,7 @@ function App() {
|
||||
);
|
||||
}
|
||||
if (activeSettingsCenterPane.key === 'about-go-navi') {
|
||||
return (
|
||||
renderSettingsCenterAboutPane()
|
||||
);
|
||||
return renderSettingsCenterAboutPane();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -8950,6 +9050,95 @@ function App() {
|
||||
{renderAboutSettingsContent()}
|
||||
</Modal>
|
||||
|
||||
<UpdateReleaseNotesModal
|
||||
open={releaseNotesModalVisible}
|
||||
onClose={closeReleaseNotesModal}
|
||||
onOpen={handleReleaseNotesModalOpen}
|
||||
darkMode={darkMode}
|
||||
version={lastUpdateInfo?.latestVersion || updateDownloadProgress.version}
|
||||
channel={lastUpdateInfo?.channel}
|
||||
releaseName={lastUpdateInfo?.releaseName}
|
||||
releasePublishedAt={lastUpdateInfo?.releasePublishedAt}
|
||||
releaseNotes={lastUpdateInfo?.releaseNotes}
|
||||
releaseNotesUrl={lastUpdateInfo?.releaseNotesUrl || aboutInfo?.releaseUrl}
|
||||
zIndex={settingsChildModalZIndex}
|
||||
downloadProgress={
|
||||
updateDownloadProgress.status === 'idle'
|
||||
? null
|
||||
: {
|
||||
status: updateDownloadProgress.status,
|
||||
percent: updateDownloadProgress.percent,
|
||||
downloaded: updateDownloadProgress.downloaded,
|
||||
total: updateDownloadProgress.total,
|
||||
message: updateDownloadProgress.message,
|
||||
}
|
||||
}
|
||||
formatBytes={formatBytes}
|
||||
progressHint={
|
||||
updateInstallAction === 'restart'
|
||||
? t('app.about.download_progress.complete_hint')
|
||||
: t('app.about.download_progress.installer_complete_hint')
|
||||
}
|
||||
footerActions={[
|
||||
lastUpdateInfo?.releaseNotesUrl || aboutInfo?.releaseUrl ? (
|
||||
<Button
|
||||
key="github"
|
||||
onClick={() => {
|
||||
const url = lastUpdateInfo?.releaseNotesUrl || aboutInfo?.releaseUrl;
|
||||
if (!url) return;
|
||||
try { BrowserOpenURL(url); } catch { window.open(url, '_blank', 'noopener,noreferrer'); }
|
||||
}}
|
||||
>
|
||||
{t('app.about.release_notes.modal.open_github')}
|
||||
</Button>
|
||||
) : null,
|
||||
(updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading') ? (
|
||||
<Button
|
||||
key="background"
|
||||
onClick={() => {
|
||||
markUpdateProgressDismissed();
|
||||
closeReleaseNotesModal();
|
||||
}}
|
||||
>
|
||||
{t('app.about.action.hide_to_background')}
|
||||
</Button>
|
||||
) : null,
|
||||
lastUpdateInfo?.hasUpdate
|
||||
&& !isLatestUpdateDownloaded
|
||||
&& updateDownloadProgress.status !== 'start'
|
||||
&& updateDownloadProgress.status !== 'downloading' ? (
|
||||
<Button
|
||||
key="download"
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownloadUpdateWithNotes}
|
||||
>
|
||||
{updateDownloadActionLabel}
|
||||
</Button>
|
||||
) : null,
|
||||
isLatestUpdateDownloaded || updateDownloadProgress.status === 'done' ? (
|
||||
<Button key="open-install-directory" onClick={openDownloadedUpdateDirectory}>
|
||||
{t('app.about.action.open_install_directory')}
|
||||
</Button>
|
||||
) : null,
|
||||
isLatestUpdateDownloaded || updateDownloadProgress.status === 'done' ? (
|
||||
<Button
|
||||
key="restart"
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
onClick={() => { void handleInstallUpdateRequest(); }}
|
||||
>
|
||||
{updateInstallActionLabel}
|
||||
</Button>
|
||||
) : null,
|
||||
(updateDownloadProgress.status !== 'start' && updateDownloadProgress.status !== 'downloading') ? (
|
||||
<Button key="close" onClick={closeReleaseNotesModal}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
) : null,
|
||||
].filter(Boolean) as React.ReactNode[]}
|
||||
/>
|
||||
|
||||
{isThemeModalOpen && (
|
||||
<Modal
|
||||
title={renderUtilityModalTitle(
|
||||
@@ -8993,65 +9182,6 @@ function App() {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={updateDownloadProgress.version
|
||||
? t('app.about.download_progress.title_with_version', { version: updateDownloadProgress.version })
|
||||
: t('app.about.download_progress.title')}
|
||||
open={updateDownloadProgress.open}
|
||||
zIndex={settingsChildModalZIndex}
|
||||
destroyOnHidden
|
||||
closable
|
||||
maskClosable
|
||||
keyboard
|
||||
onCancel={hideUpdateDownloadProgress}
|
||||
footer={updateDownloadProgress.status === 'start' || updateDownloadProgress.status === 'downloading' ? [
|
||||
<Button
|
||||
key="background"
|
||||
onClick={() => {
|
||||
markUpdateProgressDismissed();
|
||||
hideUpdateDownloadProgress();
|
||||
}}
|
||||
>
|
||||
{t('app.about.action.hide_to_background')}
|
||||
</Button>
|
||||
] : (updateDownloadProgress.status === 'done' ? [
|
||||
<Button key="close" onClick={hideUpdateDownloadProgress}>{t('common.close')}</Button>,
|
||||
<Button key="open-install-directory" onClick={openDownloadedUpdateDirectory}>
|
||||
{t('app.about.action.open_install_directory')}
|
||||
</Button>,
|
||||
<Button key="restart" type="primary" icon={<SyncOutlined />} onClick={() => { void handleInstallUpdateRequest(); }}>
|
||||
{updateInstallActionLabel}
|
||||
</Button>
|
||||
] : (updateDownloadProgress.status === 'error' ? [
|
||||
<Button key="close" onClick={hideUpdateDownloadProgress}>{t('common.close')}</Button>
|
||||
] : null))}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Progress
|
||||
percent={Math.round(updateDownloadProgress.percent)}
|
||||
status={updateDownloadProgress.status === 'error' ? 'exception' : (updateDownloadProgress.status === 'done' ? 'success' : 'active')}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
|
||||
{updateDownloadProgress.status === 'done'
|
||||
? (updateInstallAction === 'restart'
|
||||
? t('app.about.download_progress.complete_hint')
|
||||
: t('app.about.download_progress.installer_complete_hint'))
|
||||
: `${formatBytes(updateDownloadProgress.downloaded)} / ${formatBytes(updateDownloadProgress.total)}`}
|
||||
</div>
|
||||
{updateDownloadProgress.message ? (
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: updateDownloadProgress.status === 'error'
|
||||
? '#ff4d4f'
|
||||
: (darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(16,24,40,0.65)'),
|
||||
}}
|
||||
>
|
||||
{updateDownloadProgress.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{showLinuxResizeHandles && (
|
||||
<>
|
||||
{/* Linux Mint 下 frameless 仅局部可缩放:补四边四角命中层 */}
|
||||
|
||||
109
frontend/src/components/UpdateReleaseNotesModal.test.tsx
Normal file
109
frontend/src/components/UpdateReleaseNotesModal.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { create, act, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../wailsjs/runtime', () => ({
|
||||
BrowserOpenURL: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock('./common/ResizableDraggableModal', () => ({
|
||||
default: ({ open, title, children, footer }: any) => (
|
||||
open ? (
|
||||
<div data-testid="release-notes-modal">
|
||||
<div data-testid="release-notes-title">{title}</div>
|
||||
<div data-testid="release-notes-body">{children}</div>
|
||||
<div data-testid="release-notes-footer">{footer}</div>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
}));
|
||||
|
||||
import UpdateReleaseNotesModal from './UpdateReleaseNotesModal';
|
||||
|
||||
const collectText = (value: unknown): string => {
|
||||
if (value == null || typeof value === 'boolean') return '';
|
||||
if (typeof value === 'string' || typeof value === 'number') return String(value);
|
||||
if (Array.isArray(value)) return value.map(collectText).join('');
|
||||
if (typeof value === 'object' && value && 'children' in (value as any)) {
|
||||
return collectText((value as any).children);
|
||||
}
|
||||
if (typeof value === 'object' && value && 'props' in (value as any)) {
|
||||
return collectText((value as any).props?.children);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
describe('UpdateReleaseNotesModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders markdown release notes body when provided', () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<UpdateReleaseNotesModal
|
||||
open
|
||||
onClose={() => undefined}
|
||||
version="0.9.0"
|
||||
channel="latest"
|
||||
releaseNotes={"## Feature\n\n- ship in-app release notes\n"}
|
||||
releaseNotesUrl="https://github.com/Syngnat/GoNavi/releases/tag/v0.9.0"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const modal = renderer.root.findByProps({ 'data-testid': 'release-notes-modal' });
|
||||
const text = collectText(modal);
|
||||
expect(text).toContain('app.about.release_notes.modal.title');
|
||||
expect(text).toContain('0.9.0');
|
||||
expect(text).toContain('ship in-app release notes');
|
||||
expect(text).toContain('app.about.release_notes.modal.open_github');
|
||||
});
|
||||
|
||||
it('shows empty state when notes body is missing', () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<UpdateReleaseNotesModal
|
||||
open
|
||||
onClose={() => undefined}
|
||||
releaseNotesUrl="https://github.com/Syngnat/GoNavi/releases/latest"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const modal = renderer.root.findByProps({ 'data-testid': 'release-notes-modal' });
|
||||
const text = collectText(modal);
|
||||
expect(text).toContain('app.about.release_notes.modal.empty_with_link');
|
||||
});
|
||||
|
||||
it('renders download progress section alongside notes', () => {
|
||||
let renderer!: ReactTestRenderer;
|
||||
act(() => {
|
||||
renderer = create(
|
||||
<UpdateReleaseNotesModal
|
||||
open
|
||||
onClose={() => undefined}
|
||||
version="0.9.1"
|
||||
releaseNotes={"## Feature\n\n- combined download panel\n"}
|
||||
downloadProgress={{
|
||||
status: 'downloading',
|
||||
percent: 42,
|
||||
downloaded: 42,
|
||||
total: 100,
|
||||
message: 'downloading…',
|
||||
}}
|
||||
formatBytes={(value) => `${value}B`}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const modal = renderer.root.findByProps({ 'data-testid': 'release-notes-modal' });
|
||||
const text = collectText(modal);
|
||||
expect(text).toContain('app.about.release_notes.modal.download_section');
|
||||
expect(text).toContain('combined download panel');
|
||||
expect(text).toContain('42B / 100B');
|
||||
});
|
||||
});
|
||||
243
frontend/src/components/UpdateReleaseNotesModal.tsx
Normal file
243
frontend/src/components/UpdateReleaseNotesModal.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Button, Progress } from 'antd';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { BrowserOpenURL } from '../../wailsjs/runtime';
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export type UpdateReleaseNotesDownloadProgress = {
|
||||
status: 'idle' | 'start' | 'downloading' | 'done' | 'error';
|
||||
percent: number;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UpdateReleaseNotesModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 弹窗打开时回调(用于标记已读等) */
|
||||
onOpen?: () => void;
|
||||
darkMode?: boolean;
|
||||
version?: string;
|
||||
channel?: string;
|
||||
releaseName?: string;
|
||||
releasePublishedAt?: string;
|
||||
releaseNotes?: string;
|
||||
releaseNotesUrl?: string;
|
||||
/** 需高于设置中心等父层弹窗,否则会沉在背后 */
|
||||
zIndex?: number;
|
||||
/** 下载进度(与更新日志同窗展示,可边看边下) */
|
||||
downloadProgress?: UpdateReleaseNotesDownloadProgress | null;
|
||||
formatBytes?: (value: number) => string;
|
||||
progressHint?: string;
|
||||
/** 自定义底部操作(下载/安装/后台等) */
|
||||
footerActions?: React.ReactNode[];
|
||||
};
|
||||
|
||||
const formatReleaseTime = (value?: string): string => {
|
||||
const text = String(value || '').trim();
|
||||
if (!text) return '';
|
||||
const date = new Date(text);
|
||||
if (Number.isNaN(date.getTime())) return text;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用内「更新日志 + 下载进度」统一弹窗:
|
||||
* 检查更新后可读 changelog;下载时进度与日志同屏,支持边看边下。
|
||||
*/
|
||||
const UpdateReleaseNotesModal: React.FC<UpdateReleaseNotesModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onOpen,
|
||||
darkMode = false,
|
||||
version,
|
||||
channel,
|
||||
releaseName,
|
||||
releasePublishedAt,
|
||||
releaseNotes,
|
||||
releaseNotesUrl,
|
||||
zIndex,
|
||||
downloadProgress = null,
|
||||
formatBytes,
|
||||
progressHint,
|
||||
footerActions,
|
||||
}) => {
|
||||
const notes = String(releaseNotes || '').trim();
|
||||
const url = String(releaseNotesUrl || '').trim();
|
||||
const progress = downloadProgress && downloadProgress.status !== 'idle'
|
||||
? downloadProgress
|
||||
: null;
|
||||
const meta = useMemo(() => {
|
||||
const parts = [
|
||||
String(version || '').trim() || String(releaseName || '').trim(),
|
||||
String(channel || '').trim(),
|
||||
formatReleaseTime(releasePublishedAt),
|
||||
].filter(Boolean);
|
||||
return parts.join(' · ');
|
||||
}, [channel, releaseName, releasePublishedAt, version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
}
|
||||
}, [open, onOpen]);
|
||||
|
||||
const openGitHub = () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
BrowserOpenURL(url);
|
||||
} catch {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const progressStatus = progress?.status === 'error'
|
||||
? 'exception'
|
||||
: (progress?.status === 'done' ? 'success' : 'active');
|
||||
|
||||
const title = progress
|
||||
? (version
|
||||
? t('app.about.download_progress.title_with_version', { version })
|
||||
: t('app.about.download_progress.title'))
|
||||
: t('app.about.release_notes.modal.title');
|
||||
|
||||
const defaultFooter = [
|
||||
url ? (
|
||||
<Button key="github" onClick={openGitHub}>
|
||||
{t('app.about.release_notes.modal.open_github')}
|
||||
</Button>
|
||||
) : null,
|
||||
<Button key="close" type={progress ? 'default' : 'primary'} onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
title={title}
|
||||
width={640}
|
||||
style={{ top: 40 }}
|
||||
zIndex={zIndex}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: 'min(620px, calc(100vh - 160px))',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingTop: 12,
|
||||
gap: 12,
|
||||
},
|
||||
}}
|
||||
footer={footerActions && footerActions.length > 0 ? footerActions : defaultFooter}
|
||||
destroyOnHidden
|
||||
>
|
||||
{meta ? (
|
||||
<div style={{ fontSize: 12.5, color: darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)' }}>
|
||||
{meta}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{progress ? (
|
||||
<div
|
||||
style={{
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.10)' : '1px solid rgba(15,23,42,0.10)',
|
||||
background: darkMode ? 'rgba(34,197,94,0.08)' : 'rgba(22,163,74,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 650, color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(15,23,42,0.88)' }}>
|
||||
{t('app.about.release_notes.modal.download_section')}
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(progress.percent)}
|
||||
status={progressStatus}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)' }}>
|
||||
{progress.status === 'done'
|
||||
? (progressHint || t('app.about.download_progress.complete_hint'))
|
||||
: (formatBytes
|
||||
? `${formatBytes(progress.downloaded)} / ${formatBytes(progress.total)}`
|
||||
: `${Math.round(progress.percent)}%`)}
|
||||
</div>
|
||||
{progress.message ? (
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: progress.status === 'error'
|
||||
? '#ff4d4f'
|
||||
: (darkMode ? 'rgba(255,255,255,0.65)' : 'rgba(16,24,40,0.65)'),
|
||||
}}
|
||||
>
|
||||
{progress.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="gonavi-update-release-notes-body"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: progress ? 200 : 240,
|
||||
maxHeight: progress
|
||||
? 'min(360px, calc(100vh - 360px))'
|
||||
: 'min(480px, calc(100vh - 260px))',
|
||||
overflow: 'auto',
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
border: darkMode ? '1px solid rgba(255,255,255,0.10)' : '1px solid rgba(15,23,42,0.10)',
|
||||
background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(15,23,42,0.02)',
|
||||
color: darkMode ? 'rgba(255,255,255,0.88)' : 'rgba(15,23,42,0.88)',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
{notes ? (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (href) {
|
||||
try {
|
||||
BrowserOpenURL(href);
|
||||
} catch {
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
skipHtml
|
||||
>
|
||||
{notes}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div style={{ color: darkMode ? 'rgba(255,255,255,0.55)' : 'rgba(15,23,42,0.55)' }}>
|
||||
{url
|
||||
? t('app.about.release_notes.modal.empty_with_link')
|
||||
: t('app.about.release_notes.modal.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateReleaseNotesModal;
|
||||
@@ -20,6 +20,8 @@ export type UpdateInfo = {
|
||||
releaseName?: string;
|
||||
releasePublishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
/** Markdown 更新日志正文(来自 latest.json / GitHub release body) */
|
||||
releaseNotes?: string;
|
||||
assetName?: string;
|
||||
assetUrl?: string;
|
||||
assetSize?: number;
|
||||
|
||||
@@ -136,6 +136,9 @@ if (
|
||||
releaseNotesUrl: mockUpdateChannel === 'dev'
|
||||
? 'https://github.com/Syngnat/GoNavi/releases/tag/dev-latest'
|
||||
: 'https://github.com/Syngnat/GoNavi/releases/latest',
|
||||
releaseNotes: mockUpdateChannel === 'dev'
|
||||
? '## 🧪 测试版本 (Dev Build)\n\n## ✨ 新功能\n\n- 浏览器 mock:dev 通道更新日志样例\n'
|
||||
: '## ✨ 新功能\n\n- 浏览器 mock:latest 通道更新日志样例\n\n## 🐛 问题修复\n\n- 示例修复项\n',
|
||||
});
|
||||
let mockDataRootInfo: any = {
|
||||
path: 'C:/mock/.gonavi',
|
||||
|
||||
45
frontend/src/utils/updateReleaseNotesReadState.test.ts
Normal file
45
frontend/src/utils/updateReleaseNotesReadState.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildReleaseNotesReadKey,
|
||||
clearReleaseNotesReadState,
|
||||
isReleaseNotesRead,
|
||||
loadReadReleaseNotesKeys,
|
||||
markReleaseNotesRead,
|
||||
} from './updateReleaseNotesReadState';
|
||||
|
||||
describe('updateReleaseNotesReadState', () => {
|
||||
let memory: Record<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
memory = {};
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => (key in memory ? memory[key] : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
memory[key] = String(value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete memory[key];
|
||||
},
|
||||
});
|
||||
clearReleaseNotesReadState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearReleaseNotesReadState();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('builds stable channel:version keys', () => {
|
||||
expect(buildReleaseNotesReadKey({ channel: 'Latest', latestVersion: '0.9.0' })).toBe('latest:0.9.0');
|
||||
expect(buildReleaseNotesReadKey({ channel: 'dev', latestVersion: 'dev-abc1234' })).toBe('dev:dev-abc1234');
|
||||
expect(buildReleaseNotesReadKey({ latestVersion: '' })).toBe('');
|
||||
});
|
||||
|
||||
it('marks and loads read keys from localStorage', () => {
|
||||
expect(isReleaseNotesRead('latest:0.9.0')).toBe(false);
|
||||
expect(markReleaseNotesRead('latest:0.9.0')).toBe(true);
|
||||
expect(markReleaseNotesRead('latest:0.9.0')).toBe(false);
|
||||
expect(isReleaseNotesRead('latest:0.9.0')).toBe(true);
|
||||
expect(loadReadReleaseNotesKeys().has('latest:0.9.0')).toBe(true);
|
||||
});
|
||||
});
|
||||
72
frontend/src/utils/updateReleaseNotesReadState.ts
Normal file
72
frontend/src/utils/updateReleaseNotesReadState.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 本地缓存「已读」的更新日志版本,用于关于页/更新提示条上的未读提示。
|
||||
* key 形态:channel:version(与安装包名无关,便于同一版本多包形态共用已读状态)
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'gonavi.updateReleaseNotes.readKeys.v1';
|
||||
const MAX_KEYS = 40;
|
||||
|
||||
export type ReleaseNotesReadIdentity = {
|
||||
channel?: string | null;
|
||||
latestVersion?: string | null;
|
||||
};
|
||||
|
||||
export const buildReleaseNotesReadKey = (
|
||||
info: ReleaseNotesReadIdentity | null | undefined,
|
||||
): string => {
|
||||
const version = String(info?.latestVersion || '').trim();
|
||||
if (!version) return '';
|
||||
const channel = String(info?.channel || 'latest').trim().toLowerCase() || 'latest';
|
||||
return `${channel}:${version}`;
|
||||
};
|
||||
|
||||
const readStorage = (): string[] => {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeStorage = (keys: string[]): void => {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys.slice(0, MAX_KEYS)));
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
};
|
||||
|
||||
export const loadReadReleaseNotesKeys = (): Set<string> => new Set(readStorage());
|
||||
|
||||
export const isReleaseNotesRead = (key: string): boolean => {
|
||||
const normalized = String(key || '').trim();
|
||||
if (!normalized) return false;
|
||||
return loadReadReleaseNotesKeys().has(normalized);
|
||||
};
|
||||
|
||||
/** 标记已读;返回是否状态发生变化。 */
|
||||
export const markReleaseNotesRead = (key: string): boolean => {
|
||||
const normalized = String(key || '').trim();
|
||||
if (!normalized) return false;
|
||||
const current = readStorage();
|
||||
if (current.includes(normalized)) return false;
|
||||
writeStorage([normalized, ...current.filter((item) => item !== normalized)]);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const clearReleaseNotesReadState = (): void => {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user