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:
1 parent
c9c0c4cf1c
commit
bac67c1524
25 files changed
+888
-9
No files matched your search
+3
-1
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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': 'アプリを更新中',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user