feat(about): 应用内查看更新日志并与下载进度同窗

- 关于页提供「查看更新日志」入口,弹窗可滚动阅读 Markdown
- 下载进度与更新日志合并,支持边看日志边下载
- 本地缓存已读版本并展示未读提示
- 修正弹窗层级,避免被设置中心遮挡
- 补充多语言文案与单测
This commit is contained in:
Syngnat
2026-08-02 13:07:36 +08:00
parent 3fe41f3700
commit 0b6c0e8e51
13 changed files with 727 additions and 81 deletions

View File

@@ -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 仅局部可缩放:补四边四角命中层 */}

View 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');
});
});

View 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;

View File

@@ -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;

View File

@@ -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- 浏览器 mockdev 通道更新日志样例\n'
: '## ✨ 新功能\n\n- 浏览器 mocklatest 通道更新日志样例\n\n## 🐛 问题修复\n\n- 示例修复项\n',
});
let mockDataRootInfo: any = {
path: 'C:/mock/.gonavi',

View 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);
});
});

View 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
}
};