mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
feat: show a short summary of what changed after updating
The in-app updater only follows stable releases and its manifest notes just say "Release vX", so users jumping between stable versions never saw what the versions in between changed. The full release notes are too long for a popup. - Each version gets src/whats-new/<version>.json: up to 4 one-line items (new / fix / improve) in zh, zh-TW, en and ja, bundled into the app at build time. "items": [] marks a version that should not pop up. - On the first launch after an update, a dialog lists every non-empty version between the last one seen and the current one, newest first. Older versions beyond the first three are collapsed. Each version links to its page in the website changelog. - The last seen version is stored per device in settings.json (whatsNewSeenVersion) and only moves forward, so a downgrade and re-upgrade does not repeat anything. Without a record, only the current version is shown. When there is nothing to show, the version is recorded silently so the next update has a correct starting point. - The dialog waits for the welcome and new-layout dialogs. Confirming either of them also records the current version, so a 3.x user does not get two dialogs in a row. - Settings > About gets a "Recent updates" button to open the summaries again. The 4.0.0 summary is included so that list starts from 4.0; no update popup can reach it, since only 4.0.2 and later record a version. - The release workflow first requires the tagged version's summary file and validates every bundled summary with the unit test, since a malformed item would crash the dialog and put the whole app on the error screen.
This commit is contained in:
1 parent
cad592e04f
commit
165622b840
19 files changed
+1001
-15
No files matched your search
@@ -16,7 +16,62 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# 应用内更新摘要(src/whats-new/<版本>.json,见同目录 README)随安装包发出。漏写的话
|
||||
# 升级到这一版的用户什么也看不到;结构写坏的话弹窗渲染出错,整个应用落进错误页。
|
||||
# 放在最前面查,不白跑编译。推 tag 和 push main 同时发生,不能指望 CI 先跑完单测,
|
||||
# 所以这里自己跑一遍检查所有随包摘要的那份单测。
|
||||
whats-new-check:
|
||||
name: Check in-app what's new summaries
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# 手动干跑可以在任意分支上跑,那时这一版的摘要可能还没写,只在推 tag 时要求存在
|
||||
- name: Require this version's summary
|
||||
if: github.event_name == 'push' && github.ref_type == 'tag'
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
file="src/whats-new/${TAG#v}.json"
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "::error file=$file::Missing $file. Write the in-app summary for $TAG (see src/whats-new/README.md); use \"items\": [] to skip the popup."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Setup pnpm
|
||||
env:
|
||||
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install
|
||||
pnpm --version
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
|
||||
|
||||
- name: Install frontend deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate all summaries
|
||||
run: pnpm vitest run tests/config/whatsNewEntries.test.ts
|
||||
|
||||
release:
|
||||
needs: whats-new-check
|
||||
runs-on: ${{ matrix.os }}
|
||||
environment: release
|
||||
strategy:
|
||||
@@ -297,6 +352,7 @@ jobs:
|
||||
# 编译不需要任何 secret;挂 release environment 只是为了和其它平台在开头一起批准一次。
|
||||
macos-binary:
|
||||
name: Build macOS binary (${{ matrix.target }})
|
||||
needs: whats-new-check
|
||||
runs-on: macos-14
|
||||
environment: release
|
||||
strategy:
|
||||
|
||||
@@ -421,6 +421,9 @@ pub struct AppSettings {
|
||||
/// User has confirmed the one-time "new layout" dialog shown to upgrading users
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub new_layout_notice_confirmed: Option<bool>,
|
||||
/// Highest app version whose "what's new" summary the user has seen on this device
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub whats_new_seen_version: Option<String>,
|
||||
/// User has confirmed the common config first-run notice
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub common_config_confirmed: Option<bool>,
|
||||
@@ -557,6 +560,7 @@ impl Default for AppSettings {
|
||||
failover_confirmed: None,
|
||||
first_run_notice_confirmed: None,
|
||||
new_layout_notice_confirmed: None,
|
||||
whats_new_seen_version: None,
|
||||
common_config_confirmed: None,
|
||||
language: None,
|
||||
visible_apps: None,
|
||||
|
||||
@@ -86,6 +86,7 @@ import { PROMPT_APP_IDS } from "@/lib/query/prompts";
|
||||
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
|
||||
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
|
||||
import { FirstRunNoticeDialog } from "@/components/FirstRunNoticeDialog";
|
||||
import { WhatsNewNotice } from "@/components/WhatsNewDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HoverTip } from "@/components/ui/hover-tip";
|
||||
@@ -1568,6 +1569,7 @@ function App() {
|
||||
<DeepLinkImportDialog />
|
||||
<FirstRunNoticeDialog />
|
||||
<NewLayoutDialog />
|
||||
<WhatsNewNotice />
|
||||
</WindowControlsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { markSeen } from "@/lib/whatsNew";
|
||||
|
||||
/** 首次运行欢迎提示:仅当后端启动阶段保留 firstRunNoticeConfirmed 为空时弹出。 */
|
||||
export function FirstRunNoticeDialog() {
|
||||
@@ -27,11 +29,13 @@ export function FirstRunNoticeDialog() {
|
||||
if (!settings) return;
|
||||
try {
|
||||
const { webdavSync: _, ...rest } = settings;
|
||||
// 新装用户看的就是新界面,「界面改版了」弹窗一并记成已看过
|
||||
// 新装用户看的就是新界面、新版本,「界面改版了」弹窗和更新摘要一并记成已看过
|
||||
const version = await getVersion().catch(() => undefined);
|
||||
await settingsApi.save({
|
||||
...rest,
|
||||
firstRunNoticeConfirmed: true,
|
||||
newLayoutNoticeConfirmed: true,
|
||||
whatsNewSeenVersion: markSeen(settings.whatsNewSeenVersion, version),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { ChevronRight, ExternalLink, Sparkles } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNewLayoutNoticePending } from "@/components/shell/NewLayoutDialog";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import {
|
||||
WHATS_NEW_ENTRIES,
|
||||
changelogUrl,
|
||||
entriesSince,
|
||||
isNewerThanSeen,
|
||||
markSeen,
|
||||
resolveWhatsNewLanguage,
|
||||
type WhatsNewEntry,
|
||||
} from "@/lib/whatsNew";
|
||||
|
||||
/** 默认展开的版本数,更早的折叠起来,跳了很多版本也不会撑满弹窗 */
|
||||
const EXPANDED_VERSIONS = 3;
|
||||
|
||||
interface WhatsNewDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** 新到旧 */
|
||||
entries: WhatsNewEntry[];
|
||||
/** 启动弹出时传当前版本,标题写「已更新到 vX」;不传是关于页的「近期更新」 */
|
||||
updatedTo?: string;
|
||||
/** 上次看过的版本,副标题写「自 vX 以来的更新」 */
|
||||
since?: string;
|
||||
}
|
||||
|
||||
export function WhatsNewDialog({
|
||||
open,
|
||||
onClose,
|
||||
entries,
|
||||
updatedTo,
|
||||
since,
|
||||
}: WhatsNewDialogProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const language = resolveWhatsNewLanguage(i18n.language);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
const visible = showAll ? entries : entries.slice(0, EXPANDED_VERSIONS);
|
||||
const hiddenCount = entries.length - visible.length;
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
settingsApi.openExternal(url).catch((error) => {
|
||||
console.error("Failed to open changelog:", error);
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewFull = () => {
|
||||
// 只有一个版本就直接打开这一版,多个版本打开更新日志列表
|
||||
openExternal(
|
||||
changelogUrl(
|
||||
language,
|
||||
entries.length === 1 ? entries[0].version : undefined,
|
||||
),
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
zIndex="top"
|
||||
{...(since ? {} : { "aria-describedby": undefined })}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-fg-1" />
|
||||
{updatedTo
|
||||
? t("whatsNew.updatedTo", { version: updatedTo })
|
||||
: t("whatsNew.recentTitle")}
|
||||
</DialogTitle>
|
||||
{since && (
|
||||
<DialogDescription>
|
||||
{t("whatsNew.since", { version: since })}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-6 py-4">
|
||||
{visible.map((entry) => (
|
||||
<section key={entry.version}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-strong tabular-nums text-fg-1">
|
||||
v{entry.version}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-caption text-fg-2 hover:text-fg-1"
|
||||
onClick={() =>
|
||||
openExternal(changelogUrl(language, entry.version))
|
||||
}
|
||||
>
|
||||
{t("whatsNew.versionDetails")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{entry.items.map((item, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="flex items-start gap-2 text-body text-fg-1"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 rounded-control bg-subtle px-1.5 text-badge text-fg-2">
|
||||
{t(`whatsNew.types.${item.type}`)}
|
||||
</span>
|
||||
<span className="min-w-0">{item[language]}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-caption text-fg-2 hover:text-fg-1"
|
||||
onClick={() => setShowAll(true)}
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
{t("whatsNew.showEarlier", { count: hiddenCount })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleViewFull}>
|
||||
{t("whatsNew.viewFull")}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{t("whatsNew.confirm")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动时的更新摘要:当前版本比 settings.json 里记的 whatsNewSeenVersion 新,
|
||||
* 就把这之间有内容的版本弹给用户看,关掉后记下当前版本。
|
||||
* 排在欢迎弹窗和「界面改版了」后面,那两个确认时会顺手记下当前版本,不会接着弹。
|
||||
*/
|
||||
export function WhatsNewNotice() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettingsQuery();
|
||||
const newLayoutPending = useNewLayoutNoticePending(settings);
|
||||
const { data: currentVersion } = useQuery({
|
||||
queryKey: ["app-version"],
|
||||
queryFn: () => getVersion(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const [closed, setClosed] = useState(false);
|
||||
const stampedRef = useRef(false);
|
||||
|
||||
const seen = settings?.whatsNewSeenVersion;
|
||||
const due =
|
||||
settings != null &&
|
||||
settings.firstRunNoticeConfirmed === true &&
|
||||
newLayoutPending === false &&
|
||||
!!currentVersion &&
|
||||
isNewerThanSeen(currentVersion, seen);
|
||||
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
due && currentVersion
|
||||
? entriesSince(WHATS_NEW_ENTRIES, seen, currentVersion)
|
||||
: [],
|
||||
[due, seen, currentVersion],
|
||||
);
|
||||
|
||||
// 打开时把内容定格:确认后设置刷新、区间变空,关闭动画里内容不会被清掉
|
||||
const [shown, setShown] = useState<{
|
||||
entries: WhatsNewEntry[];
|
||||
since?: string;
|
||||
} | null>(null);
|
||||
if (shown === null && entries.length > 0) {
|
||||
setShown({ entries, since: seen });
|
||||
}
|
||||
|
||||
const acknowledge = useCallback(async () => {
|
||||
setClosed(true);
|
||||
if (!settings || !currentVersion) return;
|
||||
try {
|
||||
const { webdavSync: _, ...rest } = settings;
|
||||
await settingsApi.save({
|
||||
...rest,
|
||||
whatsNewSeenVersion: markSeen(
|
||||
settings.whatsNewSeenVersion,
|
||||
currentVersion,
|
||||
),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to save whatsNewSeenVersion:", error);
|
||||
}
|
||||
}, [settings, currentVersion, queryClient]);
|
||||
|
||||
// 区间里没有可显示的内容:静默记下当前版本,下次升级才有准确的起点
|
||||
useEffect(() => {
|
||||
if (!due || entries.length > 0 || stampedRef.current) return;
|
||||
stampedRef.current = true;
|
||||
void acknowledge();
|
||||
}, [due, entries.length, acknowledge]);
|
||||
|
||||
if (!shown) return null;
|
||||
|
||||
return (
|
||||
<WhatsNewDialog
|
||||
open={!closed}
|
||||
onClose={() => void acknowledge()}
|
||||
entries={shown.entries}
|
||||
updatedTo={currentVersion}
|
||||
since={shown.since}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,6 +16,8 @@ import { getVersion } from "@tauri-apps/api/app";
|
||||
import { settingsApi } from "@/lib/api";
|
||||
import { useUpdate } from "@/contexts/UpdateContext";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { WhatsNewDialog } from "@/components/WhatsNewDialog";
|
||||
import { WHATS_NEW_ENTRIES, entriesUpTo } from "@/lib/whatsNew";
|
||||
import appIcon from "@/assets/icons/app-icon.png";
|
||||
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||
|
||||
@@ -38,6 +41,11 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
() => appVersionCache === null,
|
||||
);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [whatsNewOpen, setWhatsNewOpen] = useState(false);
|
||||
const recentEntries = useMemo(
|
||||
() => (version ? entriesUpTo(WHATS_NEW_ENTRIES, version) : []),
|
||||
[version],
|
||||
);
|
||||
|
||||
const { hasUpdate, updateInfo, checkUpdate, resetDismiss, isChecking } =
|
||||
useUpdate();
|
||||
@@ -249,6 +257,17 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("settings.releaseNotes")}
|
||||
</Button>
|
||||
{recentEntries.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
size="compact"
|
||||
onClick={() => setWhatsNewOpen(true)}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{t("whatsNew.recentTitle")}
|
||||
</Button>
|
||||
)}
|
||||
<a
|
||||
href="https://github.com/farion1231/cc-switch"
|
||||
onClick={(event) => {
|
||||
@@ -260,6 +279,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
{t("settings.starPrompt")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<WhatsNewDialog
|
||||
open={whatsNewOpen}
|
||||
onClose={() => setWhatsNewOpen(false)}
|
||||
entries={recentEntries}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { APP_IDS } from "@/config/appConfig";
|
||||
import { providersApi, settingsApi } from "@/lib/api";
|
||||
import { useSettingsQuery } from "@/lib/query";
|
||||
import { markSeen } from "@/lib/whatsNew";
|
||||
import type { Settings } from "@/types";
|
||||
|
||||
const RELEASES_URL = "https://github.com/farion1231/cc-switch/releases";
|
||||
|
||||
@@ -32,20 +34,16 @@ async function hasAnyProvider(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 「界面改版了」一次性弹窗:只给升级上来的老用户看,确认后写 newLayoutNoticeConfirmed。
|
||||
* 「界面改版了」弹窗这次会不会出现:true 会,false 不会,undefined 还在查供应商。
|
||||
* - 必须已经确认过首次启动的欢迎弹窗。全新安装时后端会先导入当前配置、再预置官方
|
||||
* 供应商,单看「有没有供应商」分不出新老用户;新用户点欢迎弹窗的「我知道了」时
|
||||
* 会把这个字段一起写成 true,以后也不会再看到这里。
|
||||
* - 数据库里已经有供应商(任一应用)。
|
||||
* 弹窗盖在页面上,不占布局,晚一点出来也不会推动内容区。
|
||||
* 更新摘要弹窗也读它,排在这个弹窗后面。
|
||||
*/
|
||||
export function NewLayoutDialog() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettingsQuery();
|
||||
// 点了就先关,不等保存回来
|
||||
const [closed, setClosed] = useState(false);
|
||||
|
||||
export function useNewLayoutNoticePending(
|
||||
settings: Settings | undefined,
|
||||
): boolean | undefined {
|
||||
const eligible =
|
||||
settings != null &&
|
||||
settings.firstRunNoticeConfirmed === true &&
|
||||
@@ -54,18 +52,41 @@ export function NewLayoutDialog() {
|
||||
const { data: hasProviders } = useQuery({
|
||||
queryKey: ["new-layout-notice", "has-providers"],
|
||||
queryFn: hasAnyProvider,
|
||||
enabled: eligible && !closed,
|
||||
enabled: eligible,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const isOpen = eligible && !closed && hasProviders === true;
|
||||
return eligible ? hasProviders : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「界面改版了」一次性弹窗:只给升级上来的老用户看,确认后写 newLayoutNoticeConfirmed。
|
||||
* 弹窗盖在页面上,不占布局,晚一点出来也不会推动内容区。
|
||||
*/
|
||||
export function NewLayoutDialog() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: settings } = useSettingsQuery();
|
||||
// 点了就先关,不等保存回来
|
||||
const [closed, setClosed] = useState(false);
|
||||
const pending = useNewLayoutNoticePending(settings);
|
||||
|
||||
const isOpen = !closed && pending === true;
|
||||
|
||||
const handleAcknowledge = async () => {
|
||||
setClosed(true);
|
||||
if (!settings) return;
|
||||
try {
|
||||
const { webdavSync: _, ...rest } = settings;
|
||||
await settingsApi.save({ ...rest, newLayoutNoticeConfirmed: true });
|
||||
// 更新摘要一并记成已看,不再接着弹第二个。看到这里的是从 3.x 升上来的用户,
|
||||
// 之前没有记录,摘要本来也只会显示当前这一版;那几条是相对上一个 4.x 预览版
|
||||
// 的改动,对他们不如这个弹窗和「查看更新说明」(正式版页面是 4.0 完整说明)有用。
|
||||
const version = await getVersion().catch(() => undefined);
|
||||
await settingsApi.save({
|
||||
...rest,
|
||||
newLayoutNoticeConfirmed: true,
|
||||
whatsNewSeenVersion: markSeen(settings.whatsNewSeenVersion, version),
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
} catch (error) {
|
||||
console.error("Failed to save newLayoutNoticeConfirmed:", error);
|
||||
|
||||
@@ -4514,6 +4514,20 @@
|
||||
"viewReleaseNotes": "View release notes",
|
||||
"confirm": "Got it"
|
||||
},
|
||||
"whatsNew": {
|
||||
"updatedTo": "Updated to v{{version}}",
|
||||
"recentTitle": "Recent updates",
|
||||
"since": "What changed since v{{version}}",
|
||||
"types": {
|
||||
"new": "New",
|
||||
"fix": "Fix",
|
||||
"improve": "Improved"
|
||||
},
|
||||
"versionDetails": "Details",
|
||||
"showEarlier": "{{count}} earlier version(s)",
|
||||
"viewFull": "View full changelog",
|
||||
"confirm": "Got it"
|
||||
},
|
||||
"authCenter": {
|
||||
"helpTitle": "What is Accounts?",
|
||||
"group": {
|
||||
|
||||
@@ -4514,6 +4514,20 @@
|
||||
"viewReleaseNotes": "リリースノートを見る",
|
||||
"confirm": "OK"
|
||||
},
|
||||
"whatsNew": {
|
||||
"updatedTo": "v{{version}} に更新しました",
|
||||
"recentTitle": "最近の更新",
|
||||
"since": "v{{version}} 以降の変更",
|
||||
"types": {
|
||||
"new": "新機能",
|
||||
"fix": "修正",
|
||||
"improve": "改善"
|
||||
},
|
||||
"versionDetails": "詳細",
|
||||
"showEarlier": "さらに以前のバージョン {{count}} 件",
|
||||
"viewFull": "すべての変更内容を見る",
|
||||
"confirm": "OK"
|
||||
},
|
||||
"authCenter": {
|
||||
"helpTitle": "アカウントとは",
|
||||
"group": {
|
||||
|
||||
@@ -4515,6 +4515,20 @@
|
||||
"viewReleaseNotes": "查看更新說明",
|
||||
"confirm": "我知道了"
|
||||
},
|
||||
"whatsNew": {
|
||||
"updatedTo": "已更新至 v{{version}}",
|
||||
"recentTitle": "近期更新",
|
||||
"since": "自 v{{version}} 以來的更新",
|
||||
"types": {
|
||||
"new": "新增",
|
||||
"fix": "修復",
|
||||
"improve": "改進"
|
||||
},
|
||||
"versionDetails": "詳情",
|
||||
"showEarlier": "還有 {{count}} 個更早的版本",
|
||||
"viewFull": "查看完整更新內容",
|
||||
"confirm": "知道了"
|
||||
},
|
||||
"authCenter": {
|
||||
"helpTitle": "授權中心是什麼",
|
||||
"group": {
|
||||
|
||||
@@ -4514,6 +4514,20 @@
|
||||
"viewReleaseNotes": "查看更新说明",
|
||||
"confirm": "我知道了"
|
||||
},
|
||||
"whatsNew": {
|
||||
"updatedTo": "已更新到 v{{version}}",
|
||||
"recentTitle": "近期更新",
|
||||
"since": "自 v{{version}} 以来的更新",
|
||||
"types": {
|
||||
"new": "新增",
|
||||
"fix": "修复",
|
||||
"improve": "改进"
|
||||
},
|
||||
"versionDetails": "详情",
|
||||
"showEarlier": "还有 {{count}} 个更早的版本",
|
||||
"viewFull": "查看完整更新内容",
|
||||
"confirm": "知道了"
|
||||
},
|
||||
"authCenter": {
|
||||
"helpTitle": "授权中心是什么",
|
||||
"group": {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { compareVersions } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* 更新摘要:每个版本一份 `src/whats-new/<version>.json`,构建时打进安装包。
|
||||
* 新版安装包天然带着它自己和之前所有版本的摘要,弹窗不联网。格式见同目录 README。
|
||||
*/
|
||||
|
||||
export const WHATS_NEW_ITEM_TYPES = ["new", "fix", "improve"] as const;
|
||||
export type WhatsNewItemType = (typeof WHATS_NEW_ITEM_TYPES)[number];
|
||||
|
||||
export const WHATS_NEW_LANGUAGES = ["zh", "zh-TW", "en", "ja"] as const;
|
||||
export type WhatsNewLanguage = (typeof WHATS_NEW_LANGUAGES)[number];
|
||||
|
||||
export type WhatsNewItem = { type: WhatsNewItemType } & Record<
|
||||
WhatsNewLanguage,
|
||||
string
|
||||
>;
|
||||
|
||||
export interface WhatsNewEntry {
|
||||
version: string;
|
||||
/** 空数组 = 这一版不弹窗(纯构建、CI 之类的版本) */
|
||||
items: WhatsNewItem[];
|
||||
}
|
||||
|
||||
const modules = import.meta.glob<WhatsNewEntry>("../whats-new/*.json", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
/** 所有版本的摘要,新到旧 */
|
||||
export const WHATS_NEW_ENTRIES: WhatsNewEntry[] = Object.values(modules).sort(
|
||||
(a, b) => compareVersions(b.version, a.version),
|
||||
);
|
||||
|
||||
/** 当前版本比记录的已看版本新(或从没记录过):需要处理一次 */
|
||||
export function isNewerThanSeen(
|
||||
current: string,
|
||||
seen: string | undefined,
|
||||
): boolean {
|
||||
return !seen || compareVersions(current, seen) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动弹窗要显示的版本:`(seen, current]` 里有内容的版本,新到旧。
|
||||
* 没有记录(从还没有这个功能的版本升上来)时不知道起点,只显示当前版本。
|
||||
*/
|
||||
export function entriesSince(
|
||||
entries: WhatsNewEntry[],
|
||||
seen: string | undefined,
|
||||
current: string,
|
||||
): WhatsNewEntry[] {
|
||||
return entries.filter((entry) => {
|
||||
if (entry.items.length === 0) return false;
|
||||
if (!seen) return entry.version === current;
|
||||
return (
|
||||
compareVersions(entry.version, seen) > 0 &&
|
||||
compareVersions(entry.version, current) <= 0
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 关于页手动查看:不超过当前版本、有内容的版本,新到旧 */
|
||||
export function entriesUpTo(
|
||||
entries: WhatsNewEntry[],
|
||||
current: string,
|
||||
): WhatsNewEntry[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
entry.items.length > 0 && compareVersions(entry.version, current) <= 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 看过之后要记下的版本:只往高记。降级后不把记录改低,
|
||||
* 再升回来时看过的版本不会重复出现。
|
||||
*/
|
||||
export function markSeen(
|
||||
seen: string | undefined,
|
||||
current: string | undefined,
|
||||
): string | undefined {
|
||||
if (!current) return seen;
|
||||
return seen && compareVersions(seen, current) > 0 ? seen : current;
|
||||
}
|
||||
|
||||
export function resolveWhatsNewLanguage(language: string): WhatsNewLanguage {
|
||||
return (WHATS_NEW_LANGUAGES as readonly string[]).includes(language)
|
||||
? (language as WhatsNewLanguage)
|
||||
: "en";
|
||||
}
|
||||
|
||||
/** 官网更新日志:不带版本是列表页。官网只有 zh / en / ja,繁中用简中页面 */
|
||||
export function changelogUrl(
|
||||
language: WhatsNewLanguage,
|
||||
version?: string,
|
||||
): string {
|
||||
const siteLanguage = language === "zh-TW" ? "zh" : language;
|
||||
const base = `https://ccswitch.io/${siteLanguage}/changelog`;
|
||||
return version ? `${base}/${version}` : base;
|
||||
}
|
||||
@@ -409,6 +409,8 @@ export interface Settings {
|
||||
firstRunNoticeConfirmed?: boolean;
|
||||
// User has confirmed the one-time "new layout" dialog shown to upgrading users
|
||||
newLayoutNoticeConfirmed?: boolean;
|
||||
// Highest app version whose "what's new" summary the user has seen on this device
|
||||
whatsNewSeenVersion?: string;
|
||||
// User has confirmed the auto-sync traffic warning
|
||||
autoSyncConfirmed?: boolean;
|
||||
// User has confirmed the common config first-run notice
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": "4.0.0",
|
||||
"items": [
|
||||
{
|
||||
"type": "improve",
|
||||
"zh": "全新界面:侧边栏布局,模式移到了每个应用页的顶部",
|
||||
"zh-TW": "全新介面:側邊欄佈局,模式移到了每個應用頁的頂部",
|
||||
"en": "Redesigned interface with a sidebar; modes now sit at the top of each app page",
|
||||
"ja": "画面を一新:サイドバー構成になり、モードは各アプリのページ上部へ"
|
||||
},
|
||||
{
|
||||
"type": "new",
|
||||
"zh": "聚合模式:多家供应商的模型放进同一个模型列表,在客户端里随手切换",
|
||||
"zh-TW": "聚合模式:多家供應商的模型放進同一個模型清單,在用戶端裡隨手切換",
|
||||
"en": "Aggregation: models from several providers in one picker, switchable inside your client",
|
||||
"ja": "集約モード:複数プロバイダーのモデルを 1 つの一覧にまとめ、クライアント内で切り替え"
|
||||
},
|
||||
{
|
||||
"type": "improve",
|
||||
"zh": "会话管理重写:每一轮做了什么、改了什么、哪步失败,一目了然",
|
||||
"zh-TW": "工作階段管理重寫:每一輪做了什麼、改了什麼、哪步失敗,一目了然",
|
||||
"en": "Rewritten sessions: see what each turn did, what it changed and where it failed",
|
||||
"ja": "セッション管理を刷新:各ターンで何をし、何を変え、どこで失敗したかが一目で"
|
||||
},
|
||||
{
|
||||
"type": "improve",
|
||||
"zh": "切换供应商只替换地址、凭据、模型等关键字段,自定义配置不再丢失",
|
||||
"zh-TW": "切換供應商只替換位址、憑證、模型等關鍵欄位,自訂設定不再遺失",
|
||||
"en": "Switching providers only replaces the URL, credentials and model; custom settings are no longer lost",
|
||||
"ja": "切り替え時はアドレス・認証情報・モデルなどだけを置き換え、カスタム設定が失われなくなりました"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 更新摘要(应用内弹窗)
|
||||
|
||||
用户升级后第一次打开 CC Switch,会弹一个窗口,列出从上次看过的版本到当前版本之间每一版的几条摘要。数据就是这个目录下的文件,构建时打进安装包(`src/lib/whatsNew.ts`),弹窗不联网。
|
||||
|
||||
详细内容仍在 `docs/release-notes/`。弹窗里每个版本的「详情」链到官网 `https://ccswitch.io/<zh|en|ja>/changelog/<version>`,所以发版后要照常同步官网更新日志。
|
||||
|
||||
## 每个版本一个文件
|
||||
|
||||
文件名是版本号,例如 `4.0.2.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "4.0.2",
|
||||
"items": [
|
||||
{
|
||||
"type": "new",
|
||||
"zh": "OpenCode 可以选择思考档位",
|
||||
"zh-TW": "OpenCode 可以選擇思考檔位",
|
||||
"en": "OpenCode can now pick a thinking level",
|
||||
"ja": "OpenCode で思考レベルを選べるように"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `type`:`new`(新增)、`fix`(修复)、`improve`(改进)。
|
||||
- 每版最多 4 条,每条一句话:中文、繁中不超过 40 字,日文不超过 50 字,英文不超过 100 个字符。四种语言都要写。
|
||||
- 写用户能感知到的变化,按影响大小排序。从中文发布说明里挑最重要的几条,不要照搬原文。
|
||||
- `"items": []` 表示这一版不弹窗,适合纯构建、CI 之类的版本。文件仍然要有。
|
||||
|
||||
规则由 `tests/config/whatsNewEntries.test.ts` 检查。
|
||||
|
||||
## 什么时候写
|
||||
|
||||
和三语发布说明一起,放进打 tag 之前的 `docs(release)` 提交。`release.yml` 会先检查 `src/whats-new/<版本>.json` 是否存在,缺了直接失败,不会白跑构建。
|
||||
@@ -61,8 +61,12 @@ describe("NewLayoutDialog", () => {
|
||||
screen.queryByText("newLayoutNotice.title"),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
// 这个弹窗介绍的就是新版本,更新摘要一并记成已看,不会接着再弹一个
|
||||
expect(mocks.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ newLayoutNoticeConfirmed: true }),
|
||||
expect.objectContaining({
|
||||
newLayoutNoticeConfirmed: true,
|
||||
whatsNewSeenVersion: "4.0.0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsNewEntry } from "@/lib/whatsNew";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getAll: vi.fn(),
|
||||
save: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
version: "4.0.4",
|
||||
settings: undefined as
|
||||
| {
|
||||
firstRunNoticeConfirmed?: boolean;
|
||||
newLayoutNoticeConfirmed?: boolean;
|
||||
whatsNewSeenVersion?: string;
|
||||
}
|
||||
| undefined,
|
||||
entries: [] as WhatsNewEntry[],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
providersApi: { getAll: mocks.getAll },
|
||||
settingsApi: { save: mocks.save, openExternal: mocks.openExternal },
|
||||
}));
|
||||
vi.mock("@/lib/query", () => ({
|
||||
useSettingsQuery: () => ({ data: mocks.settings }),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/app", () => ({
|
||||
getVersion: async () => mocks.version,
|
||||
}));
|
||||
vi.mock("@/lib/whatsNew", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/whatsNew")>()),
|
||||
get WHATS_NEW_ENTRIES() {
|
||||
return mocks.entries;
|
||||
},
|
||||
}));
|
||||
|
||||
import { WhatsNewNotice } from "@/components/WhatsNewDialog";
|
||||
|
||||
const entry = (version: string, ...texts: string[]): WhatsNewEntry => ({
|
||||
version,
|
||||
items: texts.map((text) => ({
|
||||
type: "fix",
|
||||
zh: text,
|
||||
"zh-TW": text,
|
||||
en: `${text}-en`,
|
||||
ja: `${text}-ja`,
|
||||
})),
|
||||
});
|
||||
|
||||
function renderNotice() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<WhatsNewNotice />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const title = () => screen.queryByText("whatsNew.updatedTo");
|
||||
|
||||
/** 「不该弹」的用例:等异步查询都落定后再断言 */
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
describe("WhatsNewNotice", () => {
|
||||
beforeEach(() => {
|
||||
mocks.version = "4.0.4";
|
||||
mocks.settings = {
|
||||
firstRunNoticeConfirmed: true,
|
||||
newLayoutNoticeConfirmed: true,
|
||||
whatsNewSeenVersion: "4.0.1",
|
||||
};
|
||||
mocks.entries = [
|
||||
entry("4.1.0", "未来版本"),
|
||||
entry("4.0.4", "第四版甲", "第四版乙"),
|
||||
entry("4.0.3"),
|
||||
entry("4.0.2", "第二版"),
|
||||
entry("4.0.1", "第一版"),
|
||||
];
|
||||
mocks.save.mockReset().mockResolvedValue(true);
|
||||
mocks.openExternal.mockReset().mockResolvedValue(undefined);
|
||||
mocks.getAll.mockReset().mockResolvedValue({ p1: { id: "p1" } });
|
||||
});
|
||||
|
||||
it("lists every version since the last one seen and records the current version", async () => {
|
||||
renderNotice();
|
||||
expect(await screen.findByText("whatsNew.updatedTo")).toBeInTheDocument();
|
||||
expect(screen.getByText("whatsNew.since")).toBeInTheDocument();
|
||||
expect(screen.getByText("v4.0.4")).toBeInTheDocument();
|
||||
expect(screen.getByText("第四版乙")).toBeInTheDocument();
|
||||
expect(screen.getByText("v4.0.2")).toBeInTheDocument();
|
||||
expect(screen.queryByText("v4.0.1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("v4.0.3")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("v4.1.0")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "whatsNew.confirm" }));
|
||||
await waitFor(() => expect(title()).not.toBeInTheDocument());
|
||||
expect(mocks.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ whatsNewSeenVersion: "4.0.4" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows only the current version when nothing was recorded before", async () => {
|
||||
mocks.settings = {
|
||||
firstRunNoticeConfirmed: true,
|
||||
newLayoutNoticeConfirmed: true,
|
||||
};
|
||||
renderNotice();
|
||||
expect(await screen.findByText("v4.0.4")).toBeInTheDocument();
|
||||
expect(screen.queryByText("v4.0.2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("whatsNew.since")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays closed when the current version was already seen", async () => {
|
||||
mocks.settings!.whatsNewSeenVersion = "4.0.4";
|
||||
renderNotice();
|
||||
await settle();
|
||||
expect(title()).not.toBeInTheDocument();
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stays closed after a downgrade and keeps the higher version", async () => {
|
||||
mocks.version = "4.0.2";
|
||||
mocks.settings!.whatsNewSeenVersion = "4.0.4";
|
||||
renderNotice();
|
||||
await settle();
|
||||
expect(title()).not.toBeInTheDocument();
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("silently records the version when there is nothing to show", async () => {
|
||||
mocks.version = "4.0.3";
|
||||
mocks.settings!.whatsNewSeenVersion = "4.0.2";
|
||||
renderNotice();
|
||||
await waitFor(() =>
|
||||
expect(mocks.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ whatsNewSeenVersion: "4.0.3" }),
|
||||
),
|
||||
);
|
||||
expect(title()).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("waits for the welcome dialog on a fresh install", async () => {
|
||||
mocks.settings = { firstRunNoticeConfirmed: undefined };
|
||||
renderNotice();
|
||||
await settle();
|
||||
expect(title()).not.toBeInTheDocument();
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits while the new layout dialog is showing", async () => {
|
||||
mocks.settings = {
|
||||
firstRunNoticeConfirmed: true,
|
||||
whatsNewSeenVersion: "4.0.1",
|
||||
};
|
||||
renderNotice();
|
||||
await waitFor(() => expect(mocks.getAll).toHaveBeenCalled());
|
||||
await settle();
|
||||
expect(title()).not.toBeInTheDocument();
|
||||
expect(mocks.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows when the new layout dialog does not apply", async () => {
|
||||
mocks.settings = {
|
||||
firstRunNoticeConfirmed: true,
|
||||
whatsNewSeenVersion: "4.0.1",
|
||||
};
|
||||
mocks.getAll.mockResolvedValue({});
|
||||
renderNotice();
|
||||
expect(await screen.findByText("whatsNew.updatedTo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses older versions and opens the changelog list", async () => {
|
||||
mocks.settings!.whatsNewSeenVersion = "4.0.0";
|
||||
mocks.entries = [
|
||||
entry("4.0.4", "第四版"),
|
||||
entry("4.0.3", "第三版"),
|
||||
entry("4.0.2", "第二版"),
|
||||
entry("4.0.1", "第一版"),
|
||||
];
|
||||
renderNotice();
|
||||
await screen.findByText("v4.0.4");
|
||||
expect(screen.queryByText("v4.0.1")).not.toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "whatsNew.showEarlier" }),
|
||||
);
|
||||
expect(screen.getByText("v4.0.1")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "whatsNew.versionDetails" })[0],
|
||||
);
|
||||
expect(mocks.openExternal).toHaveBeenCalledWith(
|
||||
"https://ccswitch.io/zh/changelog/4.0.4",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "whatsNew.viewFull" }));
|
||||
expect(mocks.openExternal).toHaveBeenLastCalledWith(
|
||||
"https://ccswitch.io/zh/changelog",
|
||||
);
|
||||
await waitFor(() => expect(title()).not.toBeInTheDocument());
|
||||
expect(mocks.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ whatsNewSeenVersion: "4.0.4" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
WHATS_NEW_ITEM_TYPES,
|
||||
WHATS_NEW_LANGUAGES,
|
||||
type WhatsNewLanguage,
|
||||
} from "@/lib/whatsNew";
|
||||
|
||||
// 规则见 src/whats-new/README.md:弹窗只放几句话,详细内容去官网看
|
||||
const MAX_ITEMS = 4;
|
||||
const MAX_LENGTH: Record<WhatsNewLanguage, number> = {
|
||||
zh: 40,
|
||||
"zh-TW": 40,
|
||||
ja: 50,
|
||||
en: 100,
|
||||
};
|
||||
|
||||
const files = import.meta.glob<unknown>("../../src/whats-new/*.json", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
// 弹窗渲染时直接读 item.type、item[语言],结构不对会让整个应用落进错误页,
|
||||
// 所以这里对任意 JSON 都只报问题、不抛错。发版前 release.yml 也跑这份检查。
|
||||
function problemsOf(path: string, data: unknown): string[] {
|
||||
const name = path.split("/").pop()!;
|
||||
const problems: string[] = [];
|
||||
|
||||
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?\.json$/.test(name)) {
|
||||
problems.push(`${name}: file name must be <version>.json`);
|
||||
}
|
||||
if (!isObject(data)) {
|
||||
return [...problems, `${name}: must be a JSON object`];
|
||||
}
|
||||
const entry = data;
|
||||
if (`${String(entry.version)}.json` !== name) {
|
||||
problems.push(`${name}: "version" must match the file name`);
|
||||
}
|
||||
if (!Array.isArray(entry.items)) {
|
||||
return [...problems, `${name}: "items" must be an array`];
|
||||
}
|
||||
if (entry.items.length > MAX_ITEMS) {
|
||||
problems.push(`${name}: at most ${MAX_ITEMS} items`);
|
||||
}
|
||||
entry.items.forEach((item: unknown, index) => {
|
||||
const where = `${name} items[${index}]`;
|
||||
if (!isObject(item)) {
|
||||
problems.push(`${where}: must be an object`);
|
||||
return;
|
||||
}
|
||||
if (!(WHATS_NEW_ITEM_TYPES as readonly unknown[]).includes(item.type)) {
|
||||
problems.push(`${where}: unknown type ${String(item.type)}`);
|
||||
}
|
||||
for (const language of WHATS_NEW_LANGUAGES) {
|
||||
const text = item[language];
|
||||
if (typeof text !== "string" || text.trim() === "") {
|
||||
problems.push(`${where}: missing ${language}`);
|
||||
} else if ([...text].length > MAX_LENGTH[language]) {
|
||||
problems.push(
|
||||
`${where}: ${language} is longer than ${MAX_LENGTH[language]} characters`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
return problems;
|
||||
}
|
||||
|
||||
describe("what's new entries", () => {
|
||||
it("follow the format and stay short", () => {
|
||||
const problems = Object.entries(files).flatMap(([path, data]) =>
|
||||
problemsOf(path, data),
|
||||
);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("the checker catches broken entries", () => {
|
||||
expect(
|
||||
problemsOf("x/4.0.2.json", {
|
||||
version: "4.0.3",
|
||||
items: [{ type: "news", zh: "", en: "x".repeat(101) }],
|
||||
}),
|
||||
).toEqual([
|
||||
'4.0.2.json: "version" must match the file name',
|
||||
"4.0.2.json items[0]: unknown type news",
|
||||
"4.0.2.json items[0]: missing zh",
|
||||
"4.0.2.json items[0]: missing zh-TW",
|
||||
"4.0.2.json items[0]: en is longer than 100 characters",
|
||||
"4.0.2.json items[0]: missing ja",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports malformed JSON shapes instead of throwing", () => {
|
||||
expect(
|
||||
problemsOf("x/4.0.2.json", { version: "4.0.2", items: [null] }),
|
||||
).toEqual(["4.0.2.json items[0]: must be an object"]);
|
||||
expect(problemsOf("x/4.0.2.json", null)).toEqual([
|
||||
"4.0.2.json: must be a JSON object",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
changelogUrl,
|
||||
entriesSince,
|
||||
entriesUpTo,
|
||||
isNewerThanSeen,
|
||||
markSeen,
|
||||
resolveWhatsNewLanguage,
|
||||
type WhatsNewEntry,
|
||||
} from "@/lib/whatsNew";
|
||||
|
||||
const item = (text: string) => ({
|
||||
type: "fix" as const,
|
||||
zh: text,
|
||||
"zh-TW": text,
|
||||
en: text,
|
||||
ja: text,
|
||||
});
|
||||
|
||||
// 新到旧,和 WHATS_NEW_ENTRIES 的顺序一致
|
||||
const ENTRIES: WhatsNewEntry[] = [
|
||||
{ version: "4.1.0", items: [item("future")] },
|
||||
{ version: "4.0.4", items: [item("four")] },
|
||||
{ version: "4.0.3", items: [] },
|
||||
{ version: "4.0.2", items: [item("two")] },
|
||||
{ version: "4.0.1", items: [item("one")] },
|
||||
];
|
||||
|
||||
const versions = (entries: WhatsNewEntry[]) => entries.map((e) => e.version);
|
||||
|
||||
describe("entriesSince", () => {
|
||||
it("returns versions after the seen one up to the current one, skipping empty ones", () => {
|
||||
expect(versions(entriesSince(ENTRIES, "4.0.1", "4.0.4"))).toEqual([
|
||||
"4.0.4",
|
||||
"4.0.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows only the current version when nothing was recorded", () => {
|
||||
expect(versions(entriesSince(ENTRIES, undefined, "4.0.2"))).toEqual([
|
||||
"4.0.2",
|
||||
]);
|
||||
expect(entriesSince(ENTRIES, undefined, "4.0.3")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing on a downgrade", () => {
|
||||
expect(entriesSince(ENTRIES, "4.0.4", "4.0.2")).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a prerelease as older than its release", () => {
|
||||
expect(versions(entriesSince(ENTRIES, "4.0.2-beta.1", "4.0.2"))).toEqual([
|
||||
"4.0.2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("entriesUpTo", () => {
|
||||
it("lists non-empty versions up to the current one", () => {
|
||||
expect(versions(entriesUpTo(ENTRIES, "4.0.4"))).toEqual([
|
||||
"4.0.4",
|
||||
"4.0.2",
|
||||
"4.0.1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seen version", () => {
|
||||
it("only moves forward", () => {
|
||||
expect(markSeen(undefined, "4.0.2")).toBe("4.0.2");
|
||||
expect(markSeen("4.0.1", "4.0.2")).toBe("4.0.2");
|
||||
expect(markSeen("4.0.4", "4.0.2")).toBe("4.0.4");
|
||||
expect(markSeen("4.0.1", undefined)).toBe("4.0.1");
|
||||
});
|
||||
|
||||
it("is due only when the current version is newer", () => {
|
||||
expect(isNewerThanSeen("4.0.2", undefined)).toBe(true);
|
||||
expect(isNewerThanSeen("4.0.2", "4.0.1")).toBe(true);
|
||||
expect(isNewerThanSeen("4.0.2", "4.0.2")).toBe(false);
|
||||
expect(isNewerThanSeen("4.0.2", "4.0.4")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("language and links", () => {
|
||||
it("falls back to English for unknown languages", () => {
|
||||
expect(resolveWhatsNewLanguage("zh-TW")).toBe("zh-TW");
|
||||
expect(resolveWhatsNewLanguage("ja")).toBe("ja");
|
||||
expect(resolveWhatsNewLanguage("fr")).toBe("en");
|
||||
});
|
||||
|
||||
it("links to the website changelog, Traditional Chinese uses the zh pages", () => {
|
||||
expect(changelogUrl("en", "4.0.2")).toBe(
|
||||
"https://ccswitch.io/en/changelog/4.0.2",
|
||||
);
|
||||
expect(changelogUrl("zh-TW")).toBe("https://ccswitch.io/zh/changelog");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user