状态栏中的更新实现计划¶
对于 agentic worker: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans,按任务逐项实施此计划。步骤使用复选框(
- [ ])语法进行跟踪。
目标: 将自动更新胶囊从浮动在右上角的 UpdateBadge 迁移到 LogsFooter 状态栏中——一个常驻版本标识,以及一个“更新”标签页,其面板展示实时更新操作、stable/preview 通道切换器,以及来自 GitHub 的发布(更新日志/历史)列表。
架构: 纯展示/数据逻辑位于无框架辅助模块(utils/updatePresentation.js)中,并直接进行单元测试。轻量 React 组件(UpdateStatusChip、UpdatesPanel)消费这些辅助模块和现有的 updaterSlice。一个新的临时 releasesSlice 保存 GitHub 发布列表,通过新增的 Rust list_releases 命令(reqwest → GitHub Releases API,按通道过滤)获取。通道值被提升到 store slice 中,以便设置切换器和面板切换器保持同步。
技术栈: React + zustand(前端)、Tauri v2 + reqwest/serde(Rust)、vitest/jsdom(测试)、i18next(21 个语言区域)。规范:docs/superpowers/specs/2026-06-02-updates-status-bar-design.md.
文件结构¶
新建
- frontend/src/utils/updatePresentation.js — 纯辅助函数:chip 展示、发布过滤/标记。
- frontend/src/utils/updatePresentation.test.js — 辅助函数的单元测试。
- frontend/src/store/releasesSlice.ts — 临时发布列表 + 加载 action。
- frontend/src/store/releasesSlice.test.ts — slice 测试。
- frontend/src/components/UpdateStatusChip.jsx + .css — 状态栏 chip。
- frontend/src/components/UpdatesPanel.jsx + .css — 展开后的面板主体。
- 在 frontend/src-tauri/src/ 中新增 updater_channel.rs — list_releases 命令。
修改
- frontend/src/store/updaterSlice.ts — 添加 appVersion + setAppVersion;添加通道状态 updateChannel/setUpdateChannelValue。
- frontend/src/utils/updater.js — 添加 loadReleases(channel) + fetchAppVersion() 封装。
- frontend/src/components/LogsFooter.jsx — 在 SOURCES 中添加 updates 标签页,渲染 UpdatesPanel,并在 .logs-footer__right 中挂载 UpdateStatusChip。
- frontend/src/App.jsx — 移除浮动的 <UpdateBadge/>;启动时获取应用版本。
- frontend/src/pages/Settings.jsx — 将 About 标签页的通道切换器绑定到 store 值(自动同步)。
- frontend/src/i18n/locales/*.json(21 个文件)— 添加 updates.* 键。
- frontend/src-tauri/src/lib.rs — 在 generate_handler! 中注册 list_releases。
退役
- frontend/src/components/UpdateBadge.jsx + .css — 在任务 8 后删除(逻辑由 UpdateStatusChip 吸收)。
任务 1:纯展示辅助函数¶
文件:
- 新建:frontend/src/utils/updatePresentation.js
- 测试:frontend/src/utils/updatePresentation.test.js
- [ ] 步骤 1:编写失败测试
// frontend/src/utils/updatePresentation.test.js
import { describe, it, expect } from 'vitest';
import { chipPresentation, prepareReleases } from './updatePresentation';
describe('chipPresentation', () => {
it('idle → version chip', () => {
expect(chipPresentation('idle', { appVersion: '0.3.0' }))
.toMatchObject({ variant: 'idle', label: 'v0.3.0', icon: 'check' });
});
it('idle with unknown version → hidden', () => {
expect(chipPresentation('idle', { appVersion: null })).toBeNull();
});
it('checking keeps prior idle chip visible (not hidden)', () => {
expect(chipPresentation('checking', { appVersion: '0.3.0' }))
.toMatchObject({ variant: 'idle', label: 'v0.3.0' });
});
it('available → update label', () => {
expect(chipPresentation('available', { appVersion: '0.3.0', version: '0.4.0' }))
.toMatchObject({ variant: 'available', label: '0.4.0', icon: 'up' });
});
it('downloading → percent', () => {
expect(chipPresentation('downloading', { progress: 42 }))
.toMatchObject({ variant: 'downloading', label: '42%', icon: 'spin' });
});
it('ready → restart', () => {
expect(chipPresentation('ready', {})).toMatchObject({ variant: 'ready', icon: 'restart' });
});
it('error → failed', () => {
expect(chipPresentation('error', {})).toMatchObject({ variant: 'error', icon: 'alert' });
});
});
describe('prepareReleases', () => {
const raw = [
{ version: '0.4.0', name: 'v0.4.0', date: '2026-06-01', prerelease: true, notes: 'a' },
{ version: '0.3.0', name: 'v0.3.0', date: '2026-05-20', prerelease: false, notes: 'b' },
{ version: '0.2.7', name: 'v0.2.7', date: '2026-05-03', prerelease: false, notes: 'c' },
];
it('stable hides prereleases', () => {
const out = prepareReleases(raw, 'stable', '0.3.0');
expect(out.map(r => r.version)).toEqual(['0.3.0', '0.2.7']);
});
it('preview includes prereleases', () => {
const out = prepareReleases(raw, 'preview', '0.3.0');
expect(out.map(r => r.version)).toEqual(['0.4.0', '0.3.0', '0.2.7']);
});
it('marks the running version current', () => {
const out = prepareReleases(raw, 'stable', '0.3.0');
expect(out.find(r => r.version === '0.3.0').current).toBe(true);
expect(out.find(r => r.version === '0.2.7').current).toBe(false);
});
it('tolerates empty/nullish input', () => {
expect(prepareReleases(null, 'stable', '0.3.0')).toEqual([]);
});
});
- [ ] 步骤 2:运行测试,确认它们失败
运行:cd frontend && bun run test src/utils/updatePresentation.test.js
预期:失败 — “Failed to resolve import './updatePresentation'.”
- [ ] 步骤 3:编写实现
// frontend/src/utils/updatePresentation.js
/**
* Pure, framework-free mappings for the status-bar update surface.
* Kept free of React/Tauri so it is unit-testable in isolation.
*/
/**
* Map updater state to the bar chip's presentation, or null to hide it.
* `icon` is a stable token the component maps to a lucide icon.
*/
export function chipPresentation(status, { appVersion = null, version = null, progress = 0 } = {}) {
switch (status) {
case 'available':
return { variant: 'available', label: version || '', icon: 'up' };
case 'downloading':
return { variant: 'downloading', label: `${Math.round(progress)}%`, icon: 'spin' };
case 'ready':
return { variant: 'ready', label: '', icon: 'restart' };
case 'error':
return { variant: 'error', label: '', icon: 'alert' };
case 'idle':
case 'checking':
default:
// Up to date (or mid-check): show the current version chip, or hide if unknown.
return appVersion ? { variant: 'idle', label: `v${appVersion}`, icon: 'check' } : null;
}
}
/** Filter a releases array by channel, sort newest-first, and mark the running version. */
export function prepareReleases(releases, channel, appVersion) {
if (!Array.isArray(releases)) return [];
const includePre = channel === 'preview';
return releases
.filter((r) => includePre || !r.prerelease)
.slice()
.sort((a, b) => String(b.date || '').localeCompare(String(a.date || '')))
.map((r) => ({ ...r, current: !!appVersion && r.version === appVersion }));
}
- [ ] 步骤 4:运行测试以确认它们通过
运行:cd frontend && bun run test src/utils/updatePresentation.test.js
预期:PASS(11 个测试)。
- [ ] 步骤 5:提交
git add frontend/src/utils/updatePresentation.js frontend/src/utils/updatePresentation.test.js
git commit -m "feat(update): pure chip + release presentation helpers"
任务 2:扩展 updaterSlice(应用版本与更新通道)¶
文件:
- 修改:frontend/src/store/updaterSlice.ts
- 测试:frontend/src/store/updaterSlice.test.ts(追加)
- [ ] 步骤 1:添加失败测试(追加到现有的
describe('updaterSlice', …)块中)
it('tracks app version', () => {
const { get } = harness();
expect(get().appVersion).toBeNull();
get().setAppVersion('0.3.0');
expect(get().appVersion).toBe('0.3.0');
});
it('holds the update channel, defaulting to stable', () => {
const { get } = harness();
expect(get().updateChannel).toBe('stable');
get().setUpdateChannelValue('preview');
expect(get().updateChannel).toBe('preview');
get().setUpdateChannelValue('bogus'); // normalized
expect(get().updateChannel).toBe('stable');
});
- [ ] 步骤 2:运行以确认它失败
运行:cd frontend && bun run test src/store/updaterSlice.test.ts
预期:FAIL —— appVersion/updateChannel 未定义。
- [ ] 步骤 3:实现 —— 编辑
frontend/src/store/updaterSlice.ts
在顶部添加导入:
添加到 UpdaterSlice 接口中(位于 updateError 之后):
appVersion: string | null;
updateChannel: 'stable' | 'preview';
setAppVersion: (v: string | null) => void;
setUpdateChannelValue: (ch: string) => void;
添加到返回的对象中(位于 dismissUpdate 之后):
appVersion: null,
updateChannel: 'stable',
setAppVersion: (v) => set({ appVersion: v }),
setUpdateChannelValue: (ch) => set({ updateChannel: normalizeChannel(ch) }),
(normalizeChannel 对未知值返回 'stable' —— 参见 frontend/src/utils/updateChannel.js。)
- [ ] 步骤 4:运行以确认它通过
运行:cd frontend && bun run test src/store/updaterSlice.test.ts
预期:PASS(原有 5 个 + 新增 2 个)。
- [ ] 步骤 5:提交
git add frontend/src/store/updaterSlice.ts frontend/src/store/updaterSlice.test.ts
git commit -m "feat(update): app version + channel in updaterSlice"
任务 3:releasesSlice(临时发布列表)¶
文件:
- 创建:frontend/src/store/releasesSlice.ts
- 测试:frontend/src/store/releasesSlice.test.ts
- 修改:frontend/src/store/index.ts
- [ ] 步骤 1:编写失败测试
// frontend/src/store/releasesSlice.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createReleasesSlice } from './releasesSlice';
function harness(loader) {
let state: any = {};
const set = (p: any) => { state = { ...state, ...(typeof p === 'function' ? p(state) : p) }; };
state = createReleasesSlice(set as any, (() => state) as any, {} as any);
state.__loader = loader; // test seam: injected fetcher
return { get: () => state };
}
describe('releasesSlice', () => {
it('starts idle/empty', () => {
const { get } = harness();
expect(get().releases).toEqual([]);
expect(get().releasesStatus).toBe('idle');
});
it('loadReleases → loaded on success', async () => {
const data = [{ version: '0.3.0', name: 'v0.3.0', date: '2026-05-20', prerelease: false, notes: 'x' }];
const { get } = harness();
await get().loadReleases('stable', () => Promise.resolve(data));
expect(get().releasesStatus).toBe('loaded');
expect(get().releases).toEqual(data);
});
it('loadReleases → error on failure (keeps app usable)', async () => {
const { get } = harness();
await get().loadReleases('stable', () => Promise.reject(new Error('offline')));
expect(get().releasesStatus).toBe('error');
expect(get().releases).toEqual([]);
});
});
- [ ] 步骤 2:运行以确认它失败
运行:cd frontend && bun run test src/store/releasesSlice.test.ts
预期:FAIL —— 无法解析 ./releasesSlice。
- [ ] 步骤 3:实现该 slice
// frontend/src/store/releasesSlice.ts
import type { StateCreator } from 'zustand';
import { listReleases } from '../utils/updater';
export interface ReleaseInfo {
version: string;
name: string;
date: string;
prerelease: boolean;
notes: string;
}
export type ReleasesStatus = 'idle' | 'loading' | 'loaded' | 'error';
export interface ReleasesSlice {
releases: ReleaseInfo[];
releasesStatus: ReleasesStatus;
/** Load releases for a channel. `loader` is injectable for tests; defaults to the Tauri command. */
loadReleases: (channel: string, loader?: (ch: string) => Promise<ReleaseInfo[]>) => Promise<void>;
}
export const createReleasesSlice: StateCreator<ReleasesSlice, [], [], ReleasesSlice> = (set) => ({
releases: [],
releasesStatus: 'idle',
loadReleases: async (channel, loader = listReleases) => {
set({ releasesStatus: 'loading' });
try {
const data = await loader(channel);
set({ releases: Array.isArray(data) ? data : [], releasesStatus: 'loaded' });
} catch {
set({ releases: [], releasesStatus: 'error' });
}
},
});
- [ ] 步骤 4:将其组合到根 store 中 —— 编辑
frontend/src/store/index.ts
在其他 slice 导入附近添加:
import type { ReleasesSlice } from './releasesSlice';
import { createReleasesSlice } from './releasesSlice';
将 ReleasesSlice 添加到 AppStore 联合类型中:
export type AppStore = PrefsSlice & GlossarySlice & UiSlice & DubSlice & GenerateSlice & PillSlice & StoriesSlice & UpdaterSlice & GallerySlice & ReleasesSlice;
在 store 创建器内部、createUpdaterSlice 旁边添加该展开(它是临时状态 —— 不要将其添加到 partialize):
- [ ] 步骤 5:运行以确认通过
运行:cd frontend && bun run test src/store/releasesSlice.test.ts && bun run typecheck:ci
预期:PASS(3 个测试)+ typecheck 退出码 0。
- [ ] 步骤 6:提交
git add frontend/src/store/releasesSlice.ts frontend/src/store/releasesSlice.test.ts frontend/src/store/index.ts
git commit -m "feat(update): transient releasesSlice composed into store"
任务 4:Rust list_releases 命令¶
文件:
- 修改:frontend/src-tauri/src/updater_channel.rs
- 修改:frontend/src-tauri/src/lib.rs
注意:在这个以 JS 为中心的仓库流程中,Rust 没有单元测试。通过
cargo check和手动运行来验证;前端测试会 mocklistReleases,因此 JS 侧已被独立覆盖。
- [ ] 步骤 1:将命令添加到
frontend/src-tauri/src/updater_channel.rs
追加(该文件已经在 check_update 中使用了 reqwest/serde;请复用相同的 crate):
use serde::Serialize;
const RELEASES_API: &str =
"https://api.github.com/repos/debpalash/VoiceStudio/releases?per_page=30";
#[derive(Serialize)]
pub struct ReleaseInfo {
pub version: String,
pub name: String,
pub date: String,
pub prerelease: bool,
pub notes: String,
}
/// Fetch the project's GitHub releases for the changelog/history panel.
/// `channel` is accepted for symmetry with the other update commands; channel
/// filtering is applied on the frontend (prepareReleases) so this returns all.
#[tauri::command]
pub async fn list_releases(_channel: String) -> Result<Vec<ReleaseInfo>, String> {
let client = reqwest::Client::new();
let resp = client
.get(RELEASES_API)
.header("User-Agent", "VoiceStudio")
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| format!("releases request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("releases request status {}", resp.status()));
}
let arr: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("releases parse failed: {e}"))?;
let mut out = Vec::new();
if let Some(items) = arr.as_array() {
for it in items {
let tag = it.get("tag_name").and_then(|v| v.as_str()).unwrap_or("");
out.push(ReleaseInfo {
version: tag.trim_start_matches('v').to_string(),
name: it
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(tag)
.to_string(),
date: it
.get("published_at")
.and_then(|v| v.as_str())
.unwrap_or("")
.chars()
.take(10)
.collect(),
prerelease: it.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false),
notes: it.get("body").and_then(|v| v.as_str()).unwrap_or("").to_string(),
});
}
}
Ok(out)
}
(如果文件顶部已经导入了 use serde::Serialize; 或 serde_json,请不要重复 use。)
- [ ] 步骤 2:注册它——编辑
frontend/src-tauri/src/lib.rs
在 tauri::generate_handler![ … ] 列表(约第 100 行,updater_channel::check_update 旁边)中,添加:
- [ ] 步骤 3:验证它能编译
运行:cd frontend/src-tauri && cargo check
预期:编译通过(有警告也可以)。如果 serde_json 还不是依赖项,请在 frontend/src-tauri/Cargo.toml 的 [dependencies] 下添加 serde_json = "1"(它已经是 tauri 的传递依赖;优先使用现有版本),然后重新运行。
- [ ] 步骤 4:提交
git add frontend/src-tauri/src/updater_channel.rs frontend/src-tauri/src/lib.rs frontend/src-tauri/Cargo.toml
git commit -m "feat(update): list_releases Tauri command (GitHub releases)"
任务 5:前端更新器封装(loadReleases、fetchAppVersion)¶
文件:
- 修改:frontend/src/utils/updater.js
- 测试:frontend/src/utils/updater.test.js(追加)
- [ ] 步骤 1:添加失败测试(追加到
updater.test.js中已有的describe中)
describe('listReleases / fetchAppVersion', () => {
beforeEach(() => { window.__TAURI_INTERNALS__ = {}; });
afterEach(() => { delete window.__TAURI_INTERNALS__; });
it('listReleases returns [] when not in Tauri', async () => {
delete window.__TAURI_INTERNALS__;
const { listReleases } = await import('./updater');
expect(await listReleases('stable')).toEqual([]);
});
it('fetchAppVersion returns null when not in Tauri', async () => {
delete window.__TAURI_INTERNALS__;
const { fetchAppVersion } = await import('./updater');
expect(await fetchAppVersion()).toBeNull();
});
});
- [ ] 步骤 2:运行以验证它会失败
运行:cd frontend && bun run test src/utils/updater.test.js
预期:失败——listReleases/fetchAppVersion 未被导出。
- [ ] 步骤 3:实现——追加到
frontend/src/utils/updater.js
/** Fetch the project's releases (changelog/history) via the Rust command. [] outside Tauri / on error. */
export async function listReleases(channel) {
if (!isTauri()) return [];
const { invoke } = await import('@tauri-apps/api/core');
const data = await invoke('list_releases', { channel });
return Array.isArray(data) ? data : [];
}
/** Current app version via Tauri, or null outside a packaged build. */
export async function fetchAppVersion() {
if (!isTauri()) return null;
try {
const { getVersion } = await import('@tauri-apps/api/app');
return await getVersion();
} catch {
return null;
}
}
- [ ] 步骤 4:运行以验证通过
运行:cd frontend && bun run test src/utils/updater.test.js
预期:PASS(原有 + 2 个新增)。
- [ ] 步骤 5:提交
git add frontend/src/utils/updater.js frontend/src/utils/updater.test.js
git commit -m "feat(update): listReleases + fetchAppVersion wrappers"
任务 6:UpdateStatusChip 组件¶
文件:
- 创建:frontend/src/components/UpdateStatusChip.jsx、frontend/src/components/UpdateStatusChip.css
逻辑已由
updatePresentation.test.js(任务 1)覆盖。该组件只是薄视图层;无需新增单元测试,但必须通过类型检查和构建。
- [ ] 步骤 1:实现该组件
// frontend/src/components/UpdateStatusChip.jsx
// Persistent update indicator that lives in the LogsFooter bar (replaces the
// old floating UpdateBadge). Shows current version when idle; morphs into
// available / downloading / ready / error. Click opens the Updates panel.
import { useTranslation } from 'react-i18next';
import { Check, ArrowUp, Loader, RotateCw, AlertTriangle } from 'lucide-react';
import { useAppStore } from '../store';
import { chipPresentation } from '../utils/updatePresentation';
import { installUpdate } from '../utils/updater';
import './UpdateStatusChip.css';
const ICONS = { check: Check, up: ArrowUp, spin: Loader, restart: RotateCw, alert: AlertTriangle };
export default function UpdateStatusChip({ onOpen }) {
const { t } = useTranslation();
const status = useAppStore((s) => s.updateStatus);
const version = useAppStore((s) => s.updateVersion);
const appVersion = useAppStore((s) => s.appVersion);
const progress = useAppStore((s) => s.updateProgress);
const p = chipPresentation(status, { appVersion, version, progress });
if (!p) return null;
const Icon = ICONS[p.icon] || Check;
const labelText = {
idle: p.label,
available: t('update.available', { version: p.label }),
downloading: t('update.downloading', { pct: p.label.replace('%', '') }),
ready: t('update.restart'),
error: t('update.failed'),
}[p.variant];
// ready/error stay one-click (preserve today's behavior); others open the panel.
const onClick = () => {
if (p.variant === 'ready') { installUpdate(useAppStore.getState()); return; }
onOpen?.();
};
return (
<button
type="button"
className={`update-chip update-chip--${p.variant}`}
onClick={onClick}
title={t('updates.tab')}
>
<Icon size={12} className={p.icon === 'spin' ? 'spinner' : ''} />
<span className="update-chip__label">{labelText}</span>
</button>
);
}
/* frontend/src/components/UpdateStatusChip.css */
.update-chip {
display: inline-flex; align-items: center; gap: 4px;
height: 20px; padding: 0 8px;
background: none; border: 1px solid transparent; border-radius: 999px;
font: inherit; font-size: 11px; font-weight: 600; cursor: pointer;
color: var(--text-dim, #a89984);
}
.update-chip:hover { color: #ebdbb2; }
.update-chip--idle { opacity: 0.75; }
.update-chip--available { color: #b8bb26; border-color: rgba(184, 187, 38, 0.5); }
.update-chip--downloading { color: #83a598; }
.update-chip--ready { color: #b8bb26; border-color: rgba(184, 187, 38, 0.6); }
.update-chip--error { color: #fb4934; border-color: rgba(251, 73, 52, 0.5); }
.update-chip .spinner { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
- [ ] 步骤 2:验证构建和类型检查
运行:cd frontend && bun run typecheck:ci && bun run build
预期:退出码为 0;构建通过。
- [ ] 步骤 3:提交
git add frontend/src/components/UpdateStatusChip.jsx frontend/src/components/UpdateStatusChip.css
git commit -m "feat(update): UpdateStatusChip bar indicator"
任务 7:UpdatesPanel 组件¶
文件:
- 创建:frontend/src/components/UpdatesPanel.jsx、frontend/src/components/UpdatesPanel.css
- [ ] 步骤 1:实现该面板
// frontend/src/components/UpdatesPanel.jsx
// Body of the LogsFooter "Updates" tab: live update row + channel switcher +
// GitHub releases (changelog/history) list. Data is transient (releasesSlice).
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, RotateCw, AlertTriangle, RefreshCw, X } from 'lucide-react';
import toast from 'react-hot-toast';
import { useAppStore } from '../store';
import { installUpdate, checkForUpdate } from '../utils/updater';
import { prepareReleases } from '../utils/updatePresentation';
import { setChannel } from '../utils/channelControl';
import './UpdatesPanel.css';
export default function UpdatesPanel() {
const { t } = useTranslation();
const status = useAppStore((s) => s.updateStatus);
const version = useAppStore((s) => s.updateVersion);
const error = useAppStore((s) => s.updateError);
const progress = useAppStore((s) => s.updateProgress);
const appVersion = useAppStore((s) => s.appVersion);
const channel = useAppStore((s) => s.updateChannel);
const releases = useAppStore((s) => s.releases);
const releasesStatus = useAppStore((s) => s.releasesStatus);
const loadReleases = useAppStore((s) => s.loadReleases);
const dismissUpdate = useAppStore((s) => s.dismissUpdate);
const dubStep = useAppStore((s) => s.dubStep);
useEffect(() => { loadReleases(channel); }, [channel, loadReleases]);
const busy = dubStep === 'generating';
const onInstall = () => {
if (busy) { toast(t('update.busy'), { icon: '⏳' }); return; }
installUpdate(useAppStore.getState());
};
const rows = prepareReleases(releases, channel, appVersion);
return (
<div className="updates-panel">
<div className="updates-panel__live">
{status === 'available' && (
<button className="updates-panel__cta" onClick={onInstall}>
<Download size={13} /> {t('update.available', { version: version || '' })} · {t('update.install')}
</button>
)}
{status === 'downloading' && (
<span className="updates-panel__progress">
{t('update.downloading', { pct: Math.round(progress) })}
<span className="updates-panel__bar"><span style={{ width: `${progress}%` }} /></span>
</span>
)}
{status === 'ready' && (
<button className="updates-panel__cta" onClick={onInstall}>
<RotateCw size={13} /> {t('update.restart')}
</button>
)}
{status === 'error' && (
<span className="updates-panel__err">
<AlertTriangle size={13} /> {error || t('update.failed')}
<button className="updates-panel__link" onClick={onInstall}>{t('update.retry')}</button>
<button className="updates-panel__icon" onClick={dismissUpdate} aria-label={t('update.dismiss')}><X size={13} /></button>
</span>
)}
{(status === 'idle' || status === 'checking') && (
<span className="updates-panel__ok">
{t('updates.up_to_date', { version: appVersion || '' })}
<button className="updates-panel__link" onClick={() => checkForUpdate(useAppStore.getState())}>
<RefreshCw size={12} /> {t('updates.check_now')}
</button>
</span>
)}
</div>
<div className="updates-panel__channel">
<span>{t('about.update_channel')}</span>
<div className="updates-panel__seg">
{['stable', 'preview'].map((c) => (
<button
key={c}
className={`updates-panel__segbtn ${channel === c ? 'is-active' : ''}`}
onClick={() => setChannel(useAppStore.getState(), c)}
>
{t(`about.channel_${c}`)}
</button>
))}
</div>
</div>
<div className="updates-panel__releases">
<div className="updates-panel__rel-head">{t('updates.releases')}</div>
{releasesStatus === 'error' && (
<div className="updates-panel__rel-empty">
{t('updates.load_error')}
<button className="updates-panel__link" onClick={() => loadReleases(channel)}>{t('updates.retry_load')}</button>
</div>
)}
{releasesStatus === 'loading' && <div className="updates-panel__rel-empty">{t('updates.loading')}</div>}
{releasesStatus === 'loaded' && rows.length === 0 && (
<div className="updates-panel__rel-empty">{t('updates.none')}</div>
)}
{rows.map((r) => (
<div key={r.version} className={`updates-panel__rel ${r.current ? 'is-current' : ''}`}>
<div className="updates-panel__rel-row">
<span className="updates-panel__rel-ver">v{r.version}</span>
{r.current && <span className="updates-panel__rel-tag">{t('updates.current')}</span>}
{r.prerelease && <span className="updates-panel__rel-pre">{t('updates.prerelease')}</span>}
<span className="updates-panel__rel-date">{r.date}</span>
</div>
{r.notes && <pre className="updates-panel__rel-notes">{r.notes}</pre>}
</div>
))}
</div>
</div>
);
}
/* frontend/src/components/UpdatesPanel.css */
.updates-panel { padding: 8px 12px; overflow-y: auto; font-size: 12px; color: #d5c4a1; }
.updates-panel__live { display: flex; align-items: center; min-height: 26px; }
.updates-panel__cta {
display: inline-flex; align-items: center; gap: 6px;
background: rgba(184,187,38,0.14); border: 1px solid rgba(184,187,38,0.5);
color: #b8bb26; border-radius: 999px; padding: 3px 10px; font: inherit; cursor: pointer;
}
.updates-panel__ok, .updates-panel__err, .updates-panel__progress { display: inline-flex; align-items: center; gap: 8px; }
.updates-panel__err { color: #fb4934; }
.updates-panel__link { background: none; border: none; color: #83a598; cursor: pointer; font: inherit; display: inline-flex; align-items: center; gap: 3px; }
.updates-panel__icon { background: none; border: none; color: #fb4934; cursor: pointer; padding: 0 2px; }
.updates-panel__bar { display: inline-block; width: 80px; height: 3px; background: rgba(255,255,255,0.12); border-radius: 2px; overflow: hidden; }
.updates-panel__bar span { display: block; height: 100%; background: #b8bb26; }
.updates-panel__channel { display: flex; align-items: center; gap: 10px; margin: 8px 0; }
.updates-panel__seg { display: inline-flex; border: 1px solid var(--chrome-border, #3a3a3a); border-radius: 6px; overflow: hidden; }
.updates-panel__segbtn { background: none; border: none; color: var(--text-dim, #a89984); padding: 2px 10px; font: inherit; cursor: pointer; }
.updates-panel__segbtn.is-active { background: rgba(131,165,152,0.18); color: #ebdbb2; }
.updates-panel__rel-head { font-weight: 600; color: var(--text-dim, #a89984); margin: 6px 0 4px; }
.updates-panel__rel-empty { color: var(--text-dim, #a89984); display: inline-flex; gap: 8px; align-items: center; padding: 6px 0; }
.updates-panel__rel { border-top: 1px solid var(--chrome-border, #2a2a2a); padding: 6px 0; }
.updates-panel__rel.is-current { background: rgba(184,187,38,0.06); }
.updates-panel__rel-row { display: flex; align-items: center; gap: 8px; }
.updates-panel__rel-ver { font-weight: 600; }
.updates-panel__rel-tag { font-size: 10px; color: #b8bb26; border: 1px solid rgba(184,187,38,0.5); border-radius: 999px; padding: 0 6px; }
.updates-panel__rel-pre { font-size: 10px; color: #d79921; border: 1px solid rgba(215,153,33,0.5); border-radius: 999px; padding: 0 6px; }
.updates-panel__rel-date { margin-left: auto; color: var(--text-dim, #a89984); }
.updates-panel__rel-notes { white-space: pre-wrap; overflow-wrap: break-word; margin: 4px 0 0; font-size: 11px; line-height: 1.4; color: #bdae93; }
- [ ] 第 2 步:创建共享频道辅助模块
frontend/src/utils/channelControl.js(供面板与设置共同使用,避免逻辑重复)
// frontend/src/utils/channelControl.js
import { isTauri } from './updater';
import { normalizeChannel } from './updateChannel';
/** Set the update channel: update the store and persist via Tauri. Returns the normalized channel. */
export async function setChannel(store, ch) {
const next = normalizeChannel(ch);
store.setUpdateChannelValue(next);
if (!isTauri()) return next;
const { invoke } = await import('@tauri-apps/api/core');
await invoke('set_update_channel', { channel: next });
return next;
}
/** Read the persisted channel from Tauri into the store on boot. */
export async function syncChannel(store) {
if (!isTauri()) return;
try {
const { invoke } = await import('@tauri-apps/api/core');
const ch = await invoke('get_update_channel');
store.setUpdateChannelValue(ch);
} catch { /* keep default */ }
}
- [ ] 第 3 步:验证构建与类型检查
运行:cd frontend && bun run typecheck:ci && bun run build
预期结果:退出码 0;构建成功。
- [ ] 第 4 步:提交
git add frontend/src/components/UpdatesPanel.jsx frontend/src/components/UpdatesPanel.css frontend/src/utils/channelControl.js
git commit -m "feat(update): UpdatesPanel (live row + channel + releases list)"
任务 8:挂载到 LogsFooter;移除浮动徽章;启动接线¶
涉及文件:
- 修改:frontend/src/components/LogsFooter.jsx
- 修改:frontend/src/App.jsx
- 删除:frontend/src/components/UpdateBadge.jsx、frontend/src/components/UpdateBadge.css
- [ ] 第 1 步:向
LogsFooter.jsx添加 Updates 标签页与状态块
a) 在顶部添加导入:
import { Download } from 'lucide-react';
import UpdatesPanel from './UpdatesPanel';
import UpdateStatusChip from './UpdateStatusChip';
b) 将标签页添加到 SOURCES 数组中(位于 tauri 条目之后,约第 27 行):
c) 渲染面板主体。找到主体根据 active 切换的位置(即渲染 backend/frontend/tauri 日志主体的地方)。添加一个分支,使得当 active === 'updates'(且未折叠)时渲染 <UpdatesPanel />,而不是日志列表。具体来说,将现有的日志主体渲染包裹为:
{!collapsed && active === 'updates'
? <UpdatesPanel />
: (/* existing log-lines body JSX unchanged */)}
d) 在右侧区域中挂载状态块。在 <div className="logs-footer__right">(约第 375 行)内部、现有 {!collapsed && (<div className="logs-footer__actions"> … )} 之前,添加:
openTo 已存在(const openTo = (id) => { setActive(id); setCollapsed(false); };,约第 263 行),在折叠或展开状态下均可工作。
e) Updates 标签页的胶囊按钮不应尝试渲染错误/警告日志徽标。updates 对应的 SourcePill 应以零计数渲染——为该源传递 counts={{ error: 0, warn: 0, total: 0 }}(现有胶囊在计数为 0 时已经会隐藏徽标)。
- [ ] 第 2 步:移除浮动徽章 + 启动接线——编辑
frontend/src/App.jsx
a) 删除导入语句 import UpdateBadge from './components/UpdateBadge';(约第 29 行)。
b) 删除 <UpdateBadge /> 的渲染(约第 852 行)。
c) 在调用 checkForUpdate 的启动 effect 中(约第 397–406 行),同时获取应用版本并同步更新频道。在顶部添加以下导入:
import { fetchAppVersion } from './utils/updater';
import { syncChannel } from './utils/channelControl';
在该 effect 内部、现有的 checkForUpdate(useAppStore.getState()) 旁边:
fetchAppVersion().then((v) => useAppStore.getState().setAppVersion(v));
syncChannel(useAppStore.getState());
- [ ] 步骤 3:删除已退役的文件
(注意:frontend/src/utils/updater.test.js 和 updaterSlice.test.ts 没有导入 UpdateBadge,因此不受影响。如果有其他文件导入了 UpdateBadge,请用 grep 查找并移除这些引用:grep -rl UpdateBadge frontend/src。)
- [ ] 步骤 4:验证
运行:cd frontend && grep -rl UpdateBadge src || echo "no refs"; bun run typecheck:ci && bun run build
预期:"no refs";typecheck 退出码 0;构建 OK。
- [ ] 步骤 5:提交
git add frontend/src/components/LogsFooter.jsx frontend/src/App.jsx
git commit -m "feat(update): mount chip+panel in LogsFooter, retire floating UpdateBadge"
任务 9:将设置中的频道切换器绑定到 store(自动同步)¶
文件:
- 修改:frontend/src/pages/Settings.jsx
- [ ] 步骤 1:用 store + 共享辅助函数替换本地频道状态
在 Settings.jsx 中(About 标签页,约第 1069–1102 行):
a) 删除本地 const [updateChannel, setUpdateChannelState] = useState('stable'),改为从 store 读取:
b) 将 changeChannel 的函数体替换为使用共享辅助函数(让 Settings 与面板保持同步):
import { setChannel } from '../utils/channelControl';
// ...
const changeChannel = useCallback(async (ch) => {
try {
const next = await setChannel(useAppStore.getState(), ch);
toast.success(t('about.channel_set', { channel: t(`about.channel_${next}`) }));
} catch (e) {
toast.error(`Failed to set channel: ${e?.message || e}`);
}
}, [t]);
c) 现有的挂载时 invoke('get_update_channel') effect 可以移除(App.jsx 现在会在启动时调用 syncChannel)。如果保留,请改为调用 useAppStore.getState().setUpdateChannelValue(ch),而不是写入本地状态。
- [ ] 步骤 2:验证
运行:cd frontend && bun run typecheck:ci && bun run build
预期:退出码 0;构建 OK。
- [ ] 步骤 3:提交
git add frontend/src/pages/Settings.jsx
git commit -m "feat(update): Settings channel switcher shares store value (auto-sync)"
任务 10:i18n — 为全部 21 个语言环境添加 updates.* 键¶
文件:
- 修改:frontend/src/i18n/locales/*.json(全部 21 个)
- [ ] 步骤 1:向
frontend/src/i18n/locales/en.json添加英文键
插入一个全新的顶级 "updates" 块(例如紧跟在现有的 "update" 块之后,约第 12 行):
"updates": {
"tab": "Updates",
"up_to_date": "Up to date · v{{version}}",
"check_now": "Check now",
"releases": "Releases",
"current": "current",
"prerelease": "preview",
"loading": "Loading releases…",
"none": "No releases found",
"load_error": "Couldn't load releases (offline?)",
"retry_load": "Retry"
},
- [ ] 步骤 2:使用仓库的翻译脚本补齐其余 20 个语言环境
只有 en.json 是手动编辑的。仓库现有的回填脚本(scripts/translate_all.py —— deep_translator/GoogleTranslator,以 en.json 为源,将每个 frontend/src/i18n/locales/{lang}.json 中缺失的键补齐;与 #205 所用管线相同)会将新的 updates.* 块传播到其余 20 个语言环境:
注意:此功能只有
updates.*键是新增的——组件复用了已有的update.*和about.channel_*键(已对照en.json验证),因此脚本只需新增这 10 个字符串。运行后,请抽查 CJK 语言环境(zh-CN、zh-TW、ja、ko)能否正常渲染新字符串;每个语言环境都必须包含全部 10 个updates.*键(第 3 步会做一致性检查)。
- [ ] 步骤 3:验证一致性与 CJK 保护
运行:
cd frontend && node -e "const fs=require('fs');const g=require('./src/i18n/locales/en.json').updates;const files=fs.readdirSync('src/i18n/locales').filter(f=>f.endsWith('.json'));let bad=0;for(const f of files){const u=require('./src/i18n/locales/'+f).updates||{};for(const k of Object.keys(g)){if(!(k in u)){console.log('MISSING',f,k);bad++}}}console.log(bad?('FAIL '+bad):'PARITY OK')"
cd /Users/user4/orca/workspaces/VoiceStudio/main-2 && uv run python -m pytest tests/test_no_hardcoded_cjk.py -q
- [ ] 步骤 4:提交
git add frontend/src/i18n/locales
git commit -m "i18n(update): add updates.* keys across 21 locales"
任务 11:完整验证与 PR¶
文件: 无(仅验证)
- [ ] 步骤 1:运行完整测试套件
cd /Users/user4/orca/workspaces/VoiceStudio/main-2/frontend
bun run test # vitest — all suites incl. new helper/slice tests
bun run typecheck:ci # tsc gate
bun run build # production build
cd ../ && uv run python -m pytest tests/test_no_hardcoded_cjk.py -q
- [ ] 步骤 2:手动冒烟测试(打包构建,可选但推荐)
运行:bun run desktop-prod:upgrade(使用 --keep-data——不会清除用户数据)。
在运行中的应用中:悬浮胶囊已消失;LogsFooter 右侧显示 v<version> ✓;点击它会打开 Updates 标签页;频道切换器在 stable/preview 之间切换,并重新加载发布列表;在 设置 → 关于 中切换频道时,面板也会同步更新。
- [ ] 步骤 3:推送并创建 PR
git push -u origin feat/updates-status-bar
gh pr create --base main --title "feat(update): move update pill into status bar + Updates panel (changelog/channel/history)" --body-file <(echo "Implements docs/superpowers/specs/2026-06-02-updates-status-bar-design.md. Moves the floating UpdateBadge into the LogsFooter as a persistent version chip + an Updates tab (live update action, stable/preview channel switcher synced with Settings, GitHub-sourced releases list). Adds Rust list_releases command. i18n across 21 locales. Tests: chip/release helpers, releasesSlice, updater wrappers.")
自我审查¶
规格覆盖:
- 常驻 chip(空闲→错误状态)→ 任务 1、6。✓
- 更新标签页/面板(实时行 + 频道 + 版本发布)→ 任务 3、5、7、8。✓
- GitHub releases(实时、按频道过滤、离线安全)→ 任务 4、5、7。✓
- 频道切换器与设置共存/同步 → 任务 2、7(channelControl)、9。✓
- 移除浮动徽标 → 任务 8。✓
- i18n 21 个 locale + CJK 防护 → 任务 10。✓
- 跨平台一致性(纯前端 + 平台无关 Rust)→ 固有特性;已通过构建验证(任务 11)。✓
- helpers/slices/wrappers 的测试 → 任务 1、2、3、5。✓
占位符扫描: 没有“TBD”/“添加错误处理”式的缺口;每个代码步骤都展示了真实代码。唯一依赖工具链的步骤(任务 10 步骤 2,locale 回填)给出了明确的回退方案(手动翻译这 10 个字符串)和一致性检查。
类型一致性: chipPresentation/prepareReleases(任务 1)↔ 在任务 6/7 中使用。ReleaseInfo 的形状在 Rust list_releases(任务 4:version/name/date/prerelease/notes)、releasesSlice(任务 3)和 prepareReleases(任务 1)之间保持一致。setUpdateChannelValue(任务 2)被 channelControl(任务 7)和设置(任务 9)使用。loadReleases(channel, loader?) 的签名保持一致(任务 3、7)。listReleases/fetchAppVersion(任务 5)在任务 3/8 中使用。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 om/VoiceStudio