跳转至

状态栏中的更新实现计划

对于 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

在顶部添加导入:

import { normalizeChannel } from '../utils/updateChannel';

添加到 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):

      ...createReleasesSlice(set, get, api),  // transient — not in 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 和手动运行来验证;前端测试会 mock listReleases,因此 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 旁边)中,添加:

            updater_channel::list_releases,
  • [ ] 步骤 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 行):

  { id: 'updates', label: 'Updates', icon: Download },

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"> … )} 之前,添加:

          <UpdateStatusChip onOpen={() => openTo('updates')} />

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:删除已退役的文件
git rm frontend/src/components/UpdateBadge.jsx frontend/src/components/UpdateBadge.css

(注意: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 读取:

const updateChannel = useAppStore((s) => s.updateChannel);

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 个语言环境:

cd /Users/user4/orca/workspaces/VoiceStudio/main-2
uv run python scripts/translate_all.py

注意:此功能只有 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
预期:"PARITY OK";CJK 保护通过(i18n 层之外没有硬编码的 CJK 文本——所有新字符串都在语言环境文件中)。

  • [ ] 步骤 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
预期:vitest 全部通过(现有 + 新增);typecheck 退出码 0;构建 OK;CJK 保护通过。

  • [ ] 步骤 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