feat: display multilingual release notes in version management

- Support Simplified Chinese, Traditional Chinese, English, and Japanese
- Follow the UI language and show a notice when notes are unavailable
- Avoid language and content fallbacks
- Generate Release bodies and update manifests from per-version Markdown
- Add collapsible notes, publication dates, and links to full releases
- Test publishing, legacy manifest compatibility, and localized rendering
This commit is contained in:
lzt404 committed 2026-09-19 20:42:30 +08:00
1 parent c9c0c4cf1c
commit bac67c1524
25 files changed
+888 -9

No files matched your search

+3 -1
View File
@@ -12,13 +12,15 @@ import {
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { Download, RefreshCw } from 'lucide-react';
import { getCurrentLocale, translate, useI18n } from './i18n';
import { getCurrentLocale, translate, useI18n, type AppLocale } from './i18n';
export type AppUpdateInfo = {
currentVersion: string;
latestVersion: string;
updateAvailable: boolean;
releaseUrl: string;
releaseNotes: Partial<Record<AppLocale, string>> | null;
publishedAt: string;
autoUpdateSupported: boolean;
downloadSizeBytes: number | null;
unsupportedReason: string | null;
+105
View File
@@ -0,0 +1,105 @@
import { useId, useState } from 'react';
import { ChevronDown, ExternalLink, FileText } from 'lucide-react';
import Markdown from 'react-markdown';
import type { AppUpdateInfo } from '../appUpdate';
import { useI18n } from '../i18n';
type Props = {
info: Pick<AppUpdateInfo, 'latestVersion' | 'releaseNotes' | 'publishedAt'> | null;
checking: boolean;
failed: boolean;
onOpenUrl: (url?: string) => void | Promise<void>;
};
export function releaseNotesUrl(value: string): string {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch {
return '';
}
}
export function AppReleaseNotes({ info, checking, failed, onOpenUrl }: Props) {
const { t, formatDate, locale } = useI18n();
const [expanded, setExpanded] = useState(true);
const contentId = useId();
const headingId = useId();
const notes = info?.releaseNotes?.[locale]?.trim();
const publishedAt = info?.publishedAt && Number.isFinite(Date.parse(info.publishedAt))
? info.publishedAt
: null;
const emptyMessage = info
? t('appUpdate.notes.empty')
: checking
? t('appUpdate.notes.loading')
: failed
? t('appUpdate.notes.failed')
: t('appUpdate.notes.notChecked');
return (
<section className="app-release-notes" aria-labelledby={headingId}>
<header className="app-release-notes-heading">
<div>
<FileText size={17} aria-hidden="true" />
<h2 id={headingId}>{t('appUpdate.notes.title')}</h2>
</div>
<button
type="button"
className="app-release-notes-toggle"
aria-expanded={expanded}
aria-controls={contentId}
onClick={() => setExpanded((current) => !current)}
>
{t(expanded ? 'appUpdate.notes.collapse' : 'appUpdate.notes.expand')}
<ChevronDown size={15} className={expanded ? 'is-expanded' : ''} aria-hidden="true" />
</button>
</header>
<div id={contentId} hidden={!expanded}>
<div className="app-release-notes-body">
{info ? (
<div className="app-release-notes-meta">
<strong>{`v${info.latestVersion.replace(/^v/i, '')}`}</strong>
<span className="app-release-notes-latest">{t('appUpdate.latest')}</span>
{publishedAt ? (
<time dateTime={publishedAt}>
{t('appUpdate.notes.publishedAt', {
date: formatDate(publishedAt, { year: 'numeric', month: 'long', day: 'numeric' }),
})}
</time>
) : null}
</div>
) : null}
{notes ? (
<div className="app-release-notes-markdown" lang={locale}>
<Markdown
skipHtml
disallowedElements={['img']}
urlTransform={releaseNotesUrl}
components={{
h1: ({ children }) => <h3 className="release-notes-subtitle">{children}</h3>,
h2: ({ children }) => <h3 className="release-notes-category">{children}</h3>,
a: ({ href, children }) => href ? (
<a href={href} onClick={(event) => { event.preventDefault(); void onOpenUrl(href); }}>
{children}
</a>
) : <span>{children}</span>,
}}
>
{notes}
</Markdown>
</div>
) : (
<p className="app-release-notes-empty" role="status">{emptyMessage}</p>
)}
</div>
<footer className="app-release-notes-footer">
<button type="button" className="app-release-notes-toggle" onClick={() => void onOpenUrl()}>
{t('appUpdate.notes.openRelease')}
<ExternalLink size={14} aria-hidden="true" />
</button>
</footer>
</div>
</section>
);
}
+9
View File
@@ -1394,6 +1394,15 @@ export const jaOverrides = {
'appUpdate.catalog.unchanged': 'モデルカタログは最新です',
'appUpdate.installNow': '今すぐ更新',
'appUpdate.openRelease': 'ダウンロードページを開く',
'appUpdate.notes.title': 'ソフトウェアの更新内容',
'appUpdate.notes.expand': '展開',
'appUpdate.notes.collapse': '折りたたむ',
'appUpdate.notes.publishedAt': '{date}公開',
'appUpdate.notes.openRelease': 'リリースノート全体を見る',
'appUpdate.notes.empty': '現在の言語の更新内容を取得できませんでした。',
'appUpdate.notes.loading': '更新内容を取得中…',
'appUpdate.notes.failed': '更新内容を取得できませんでした。後でもう一度確認してください。',
'appUpdate.notes.notChecked': 'ソフトウェアの更新を確認すると、最新版の更新内容が表示されます。',
'appUpdate.confirmTitle': 'アプリ更新をインストール',
'appUpdate.confirmDescription': '{version} をダウンロードしてインストールします。アプリは再起動し、管理中のコアは一時停止後に復帰します。',
'appUpdate.progressTitle': 'アプリを更新中',
+9
View File
@@ -1394,6 +1394,15 @@ export const en: Record<MessageKey, string> = {
'appUpdate.catalog.unchanged': 'Model catalog is already up to date',
'appUpdate.installNow': 'Update Now',
'appUpdate.openRelease': 'Open Download Page',
'appUpdate.notes.title': 'Release notes',
'appUpdate.notes.expand': 'Expand',
'appUpdate.notes.collapse': 'Collapse',
'appUpdate.notes.publishedAt': 'Published {date}',
'appUpdate.notes.openRelease': 'View full release notes',
'appUpdate.notes.empty': 'Release notes are not available in the current language.',
'appUpdate.notes.loading': 'Loading release notes…',
'appUpdate.notes.failed': 'Unable to load release notes. Please check again later.',
'appUpdate.notes.notChecked': 'Check for software updates to see the latest release notes.',
'appUpdate.confirmTitle': 'Install Application Update',
'appUpdate.confirmDescription': 'Version {version} will be downloaded and installed. The app will restart, and a managed core will briefly stop and resume.',
'appUpdate.progressTitle': 'Updating Application',
+9
View File
@@ -1393,6 +1393,15 @@ export const zhCN = {
'appUpdate.catalog.unchanged': '模型目录已是最新版本',
'appUpdate.installNow': '立即升级',
'appUpdate.openRelease': '打开下载页面',
'appUpdate.notes.title': '软件更新说明',
'appUpdate.notes.expand': '展开',
'appUpdate.notes.collapse': '收起',
'appUpdate.notes.publishedAt': '{date}发布',
'appUpdate.notes.openRelease': '查看完整发布说明',
'appUpdate.notes.empty': '暂未获取到当前语言的更新说明。',
'appUpdate.notes.loading': '正在获取更新说明…',
'appUpdate.notes.failed': '暂时无法获取更新说明,请稍后重新检查。',
'appUpdate.notes.notChecked': '检查软件更新后,将显示最新版的更新说明。',
'appUpdate.confirmTitle': '安装软件更新',
'appUpdate.confirmDescription': '将下载并安装 {version}。应用会自动重启,正在运行的托管内核会短暂停止并恢复。',
'appUpdate.progressTitle': '正在升级软件',
+12 -1
View File
@@ -7,5 +7,16 @@ export { en, zhCN };
export type { MessageKey };
export type MessageVariables = Record<string, string | number>;
export const zhTW: Record<MessageKey, string> = createTraditionalMessages(zhCN);
export const zhTW: Record<MessageKey, string> = {
...createTraditionalMessages(zhCN),
'appUpdate.notes.title': '軟體更新說明',
'appUpdate.notes.expand': '展開',
'appUpdate.notes.collapse': '收合',
'appUpdate.notes.publishedAt': '{date}發布',
'appUpdate.notes.openRelease': '查看完整發布說明',
'appUpdate.notes.empty': '尚未取得目前語言的更新說明。',
'appUpdate.notes.loading': '正在取得更新說明…',
'appUpdate.notes.failed': '暫時無法取得更新說明,請稍後重新檢查。',
'appUpdate.notes.notChecked': '檢查軟體更新後,將顯示最新版的更新說明。',
};
export const ja: Record<MessageKey, string> = jaOverrides;
+10 -2
View File
@@ -16,6 +16,7 @@ import { useI18n } from '../i18n';
import { useAppUpdate } from '../appUpdate';
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { createVersionManagementVisitTracker } from '../services/versionManagementVisits';
import { AppReleaseNotes } from '../components/AppReleaseNotes';
export type CoreInstallResult = {
version: string;
@@ -319,9 +320,9 @@ export function VersionManagementPage() {
setCancellingInstall(false);
};
const openAppRelease = async () => {
const openAppRelease = async (url = appUpdate?.releaseUrl || APP_RELEASE_URL) => {
try {
await invoke('open_external_url', { url: appUpdate?.releaseUrl || APP_RELEASE_URL });
await invoke('open_external_url', { url });
} catch (error) {
showNotice({ key: 'kernel.error.openUpdate', variables: { error: String(error) } }, 'error');
}
@@ -711,6 +712,13 @@ export function VersionManagementPage() {
</div>
</article>
</div>
<AppReleaseNotes
key={appUpdate?.latestVersion ?? 'pending'}
info={appUpdate}
checking={checkingAppUpdate}
failed={Boolean(appUpdateError)}
onOpenUrl={openAppRelease}
/>
</section>
{customMirrorDialogOpen ? (
+176 -1
View File
@@ -1124,7 +1124,8 @@ textarea:focus-visible {
}
.version-source-row,
.version-list-item {
.version-list-item,
.app-release-notes {
border: 1px solid var(--theme-e3e1db);
border-radius: 14px;
background: var(--theme-fffdf8);
@@ -1382,6 +1383,180 @@ textarea:focus-visible {
white-space: nowrap;
}
.app-release-notes {
min-width: 0;
overflow: hidden;
}
.app-release-notes-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 20px 26px;
}
.app-release-notes-heading > div {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.app-release-notes-heading h2 {
margin: 0;
font-size: var(--font-size-panel-title);
}
.app-release-notes-heading svg {
flex: none;
color: var(--theme-7a746c);
}
.app-release-notes-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: none;
min-height: 32px;
padding: 3px 0;
border: 0;
background: transparent;
color: var(--theme-6d6760);
font-size: var(--font-size-meta);
cursor: pointer;
}
.app-release-notes-toggle:hover {
color: var(--theme-2d2a26);
}
.app-release-notes-toggle .is-expanded {
transform: rotate(180deg);
}
.app-release-notes-body {
padding: 0 26px 20px;
}
.app-release-notes-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 9px;
padding-bottom: 20px;
border-bottom: 1px solid var(--theme-e8e5dc);
}
.app-release-notes-meta strong {
font-family: var(--font-family-mono, monospace);
font-size: var(--font-size-panel-title);
overflow-wrap: anywhere;
}
.app-release-notes-latest {
padding: 2px 7px;
border-radius: 5px;
background: var(--theme-dce8df);
color: var(--theme-2f6b3f);
font-size: var(--font-size-meta);
}
.app-release-notes-meta time {
margin-left: auto;
color: var(--theme-7a746c);
font-size: var(--font-size-meta);
}
.app-release-notes-markdown {
display: grid;
grid-template-columns: minmax(44px, auto) minmax(0, 1fr);
align-items: start;
gap: 18px 22px;
margin-top: 20px;
color: var(--theme-2d2a26);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
overflow-wrap: anywhere;
}
.app-release-notes-markdown > * {
grid-column: 2;
min-width: 0;
margin: 0;
}
.app-release-notes-markdown > .release-notes-category {
grid-column: 1;
max-width: 160px;
padding: 3px 9px;
border-radius: 5px;
background: var(--theme-faf9f5);
color: var(--theme-6d6760);
font-size: var(--font-size-meta);
font-weight: 600;
text-align: center;
}
.app-release-notes-markdown > .release-notes-subtitle {
grid-column: 1 / -1;
}
.app-release-notes-markdown ul,
.app-release-notes-markdown ol {
padding-left: 20px;
}
.app-release-notes-markdown li + li {
margin-top: 7px;
}
.app-release-notes-markdown li p {
margin: 5px 0;
}
.app-release-notes-markdown a {
color: var(--theme-3f6f98);
text-decoration: underline;
}
.app-release-notes-markdown pre {
white-space: pre-wrap;
padding: 12px;
border-radius: 8px;
background: var(--theme-faf9f5);
}
.app-release-notes-markdown blockquote {
border-left: 3px solid var(--theme-e3e1db);
padding-left: 14px;
color: var(--theme-6d6760);
}
.app-release-notes-empty {
margin: 18px 0 0;
color: var(--theme-7a746c);
font-size: var(--font-size-body);
}
.app-release-notes-footer {
display: flex;
justify-content: flex-end;
padding: 10px 26px;
border-top: 1px solid var(--theme-e8e5dc);
background: var(--theme-faf9f5);
}
@media (max-width: 640px) {
.app-release-notes-heading { padding: 16px; }
.app-release-notes-body { padding: 0 16px 16px; }
.app-release-notes-footer { padding: 10px 16px; }
.app-release-notes-markdown { gap: 14px 12px; }
.app-release-notes-markdown > .release-notes-category { max-width: 80px; }
.app-release-notes-meta time { width: 100%; margin-left: 0; }
}
.client-api-panel {
margin-top: 0;
}