新增: 应用内版本更新与自动升级全链路

后端 update.rs: app_check_update(拉 latest.json+ed25519 签名校验) /
app_download_install_update / prefs 读写 / mark_checked / mark_notified 五命令,
lib.rs 注册;tauri.conf updater 段(endpoints=app.1216.top/download/devflow/latest.json
+ pubkey + createUpdaterArtifacts) + tauri-plugin-updater 依赖 + capabilities 权限。

前端 UpdateBadge.vue(顶栏徽章+下拉) / useUpdateCheck.ts(检查/下载/安装状态机) /
AboutPanel.vue(设置页关于面板) + AppLayout 接线 + SettingsNav/searchIndex 索引 +
i18n zh/en settings 键。

运维 scripts/publish-release.ps1(发版) + swap-install.ps1(换装校验) +
设计文档 版本更新与自动升级-设计-2026-10-09.md。

验收: cargo check -p devflow EXIT 0 + vue-tsc EXIT 0 + build EXIT 0。
This commit is contained in:
lxy
2026-10-09 22:53:14 +08:00
parent c70faa7bb8
commit a8c92e9619
27 changed files with 2021 additions and 24 deletions
+3
View File
@@ -6,6 +6,8 @@
<!-- 主窗口:完整布局 -->
<div v-else class="df-shell">
<AppSidebar />
<!-- 全局更新徽章(环境式更新,任意页面可见) -->
<UpdateBadge />
<!-- ═══ Main (页面内容) ═══ -->
<!-- 仅在 AI 面板可见且最大化时隐藏主内容;面板关闭时无论 maximized 状态如何都显示 -->
@@ -29,6 +31,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAiStore } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings'
import AppSidebar from './AppSidebar.vue'
import UpdateBadge from './UpdateBadge.vue'
const props = defineProps<{
isDetached: boolean
+255
View File
@@ -0,0 +1,255 @@
<template>
<!-- ═══ 全局更新徽章(环境式更新,设计 §八 C4)═══
发现新版 → 右上角非模态徽章(不弹窗打断);点击展开版本详情 + 升级/稍后。
全局挂载(AppLayout),任意页面可见。 -->
<Transition name="update-badge">
<div v-if="visible" class="update-badge-root">
<button
type="button"
class="update-badge-btn"
:class="{ 'is-active': expanded }"
:title="$t('settings.updateBadgeTitle')"
@click="expanded = !expanded"
>
<span class="update-badge-dot"></span>
<span class="update-badge-text">{{ $t('settings.updateBadgeText', { version: info?.version }) }}</span>
</button>
<Transition name="update-pop">
<div v-if="expanded" class="update-pop">
<div class="update-pop-head">{{ $t('settings.updateAvailableTitle', { version: info?.version }) }}</div>
<div v-if="info?.notes" class="update-pop-notes">{{ info.notes }}</div>
<!-- 下载进度 -->
<div v-if="progress" class="update-pop-progress">
<div class="update-pop-bar">
<div class="update-pop-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<div class="update-pop-progress-text">{{ progressText }}</div>
</div>
<div v-if="installError" class="update-pop-error">{{ installError }}</div>
<div class="update-pop-actions">
<button class="btn btn-ghost btn-sm" :disabled="installing" @click="onLater">
{{ $t('settings.updateLater') }}
</button>
<button class="btn btn-primary btn-sm" :disabled="installing" @click="onInstall">
{{ installing ? $t('settings.updateInstalling') : $t('settings.updateNow') }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import type { UpdateAvailablePayload } from '@/composables/useUpdateCheck'
// ============================================================
// 全局更新徽章 — 消费 useUpdateCheck 广播的 app-update-available 事件。
// 环境式:平时零存在感;有新版才显徽章;点击展开详情;不自动安装。
// ============================================================
const { t } = useI18n()
const visible = ref(false)
const expanded = ref(false)
const info = ref<UpdateAvailablePayload | null>(null)
const installing = ref(false)
const progress = ref<{ stage: string; downloaded: number; total: number | null } | null>(null)
const installError = ref('')
let unlistenAvailable: UnlistenFn | null = null
let unlistenProgress: UnlistenFn | null = null
onMounted(async () => {
try {
unlistenAvailable = await listen<UpdateAvailablePayload>('app-update-available', (e) => {
info.value = e.payload
visible.value = true
})
} catch {
/* 静默 */
}
try {
unlistenProgress = await listen<{ stage: string; downloaded: number; total: number | null }>(
'app-update-progress',
(e) => {
progress.value = e.payload
},
)
} catch {
/* 静默 */
}
})
onUnmounted(() => {
unlistenAvailable?.()
unlistenProgress?.()
unlistenAvailable = null
unlistenProgress = null
})
const progressPercent = computed(() => {
const p = progress.value
if (!p || p.stage !== 'downloading' || !p.total || p.total === 0) return 0
return Math.min(100, Math.round((p.downloaded / p.total) * 100))
})
const progressText = computed(() => {
const p = progress.value
if (!p) return ''
if (p.stage === 'installing') return t('settings.updateInstallingStage')
if (!p.total) return t('settings.updateDownloadingUnknown')
const mb = (n: number) => (n / 1048576).toFixed(1)
return t('settings.updateDownloading', {
percent: progressPercent.value,
done: mb(p.downloaded),
total: mb(p.total),
})
})
function onLater() {
visible.value = false
expanded.value = false
}
async function onInstall() {
if (installing.value) return
installing.value = true
installError.value = ''
progress.value = null
try {
await settingsApi.downloadAndInstallUpdate()
} catch (e) {
installError.value = t('settings.updateInstallFailed', { error: String(e) })
installing.value = false
progress.value = null
}
}
</script>
<style scoped>
.update-badge-root {
position: fixed;
top: 12px;
right: 16px;
z-index: 2000;
}
.update-badge-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 0.5px solid var(--df-accent);
border-radius: 999px;
background: var(--df-bg-card);
color: var(--df-accent);
font-size: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: filter 0.15s;
}
.update-badge-btn:hover {
filter: brightness(1.1);
}
.update-badge-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--df-accent);
animation: update-pulse 1.6s ease-in-out infinite;
}
@keyframes update-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.update-pop {
position: absolute;
top: 36px;
right: 0;
width: 320px;
padding: 14px 16px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
background: var(--df-bg-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.update-pop-head {
font-size: 13px;
font-weight: 600;
color: var(--df-accent);
margin-bottom: 8px;
}
.update-pop-notes {
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.5;
white-space: pre-wrap;
max-height: 160px;
overflow-y: auto;
margin-bottom: 12px;
}
.update-pop-progress {
margin-bottom: 12px;
}
.update-pop-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
overflow: hidden;
}
.update-pop-fill {
height: 100%;
background: var(--df-accent);
transition: width 0.2s ease;
}
.update-pop-progress-text {
margin-top: 6px;
font-size: 12px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
}
.update-pop-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5;
word-break: break-all;
}
.update-pop-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.update-pop-actions .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.update-badge-enter-active,
.update-badge-leave-active {
transition: opacity 0.25s, transform 0.25s;
}
.update-badge-enter-from,
.update-badge-leave-to {
opacity: 0;
transform: translateY(-8px);
}
.update-pop-enter-active,
.update-pop-leave-active {
transition: opacity 0.15s;
}
.update-pop-enter-from,
.update-pop-leave-to {
opacity: 0;
}
</style>
+413
View File
@@ -0,0 +1,413 @@
<template>
<!-- ═══ 关于 DevFlow(版本可见 + 更新入口)═══
B1(L0):版本号/构建时间/git hash 展示。
B2(L1):检查更新接入 + 自动检查开关/间隔 + 更新可用提示。
下载安装链路在 B3(此处「立即升级」按钮先禁用,提示即将上线)。 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.sectionAbout') }}</h2>
</div>
<div class="general-settings">
<!-- ═══ 当前版本(编译期注入,单一源头) ═══ -->
<div class="setting-row inline-control-row" data-setting-key="appVersion">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelAppVersion') }}</span>
<span class="about-version" :title="versionTitle">{{ versionText }}</span>
</div>
<span class="setting-desc">{{ $t('settings.descAppVersion') }}</span>
</div>
<!-- ═══ 检查更新 ═══ -->
<div class="setting-row inline-control-row" data-setting-key="checkUpdate">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelCheckUpdate') }}</span>
<button
class="btn btn-ghost btn-sm about-check-btn"
:disabled="checking || !versionLoaded"
@click="onCheckUpdate"
>
{{ checking ? $t('settings.checkUpdateChecking') : $t('settings.checkUpdateAction') }}
</button>
</div>
<span class="setting-desc">{{ checkResultText }}</span>
</div>
<!-- ═══ 自动检查开关 ═══ -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelAutoCheckUpdate') }}</span>
<input
v-model="autoCheck"
type="checkbox"
class="setting-checkbox"
@change="onAutoCheckChange"
/>
</div>
<span class="setting-desc">{{ $t('settings.descAutoCheckUpdate') }}</span>
</div>
<!-- ═══ 检查间隔(自动检查开启时可用) ═══ -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelCheckInterval') }}</span>
<select
v-model.number="intervalMinutes"
class="setting-select"
:disabled="!autoCheck"
@change="onIntervalChange"
>
<option :value="5">{{ $t('settings.interval5m') }}</option>
<option :value="15">{{ $t('settings.interval15m') }}</option>
<option :value="30">{{ $t('settings.interval30m') }}</option>
<option :value="60">{{ $t('settings.interval60m') }}</option>
</select>
</div>
<span class="setting-desc">{{ $t('settings.descCheckInterval') }}</span>
</div>
<!-- ═══ 上次检查时间 ═══ -->
<div class="setting-row inline-control-row">
<div class="inline-head">
<span class="setting-label">{{ $t('settings.labelLastCheck') }}</span>
<span class="about-last-check">{{ lastCheckText }}</span>
</div>
</div>
</div>
<!-- ═══ 更新可用提示卡(检测到新版时显示) ═══ -->
<Transition name="update-card">
<div v-if="updateInfo" class="update-card">
<div class="update-card-head">
<span class="update-card-title">{{ $t('settings.updateAvailableTitle', { version: updateInfo.version }) }}</span>
</div>
<div v-if="updateInfo.notes" class="update-card-notes">{{ updateInfo.notes }}</div>
<!-- 下载进度(下载中/安装中显示) -->
<div v-if="progress" class="update-progress">
<div class="update-progress-bar">
<div class="update-progress-fill" :style="{ width: progressPercent + '%' }"></div>
</div>
<div class="update-progress-text">{{ progressText }}</div>
</div>
<!-- 错误提示 -->
<div v-if="installError" class="update-error">{{ installError }}</div>
<div class="update-card-actions">
<button
class="btn btn-ghost btn-sm"
:disabled="installing"
@click="onLater"
>
{{ $t('settings.updateLater') }}
</button>
<button
class="btn btn-primary btn-sm"
:disabled="installing"
@click="onInstall"
>
{{ installing ? $t('settings.updateInstalling') : $t('settings.updateNow') }}
</button>
</div>
</div>
</Transition>
</section>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { settingsApi } from '@/api'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
// ============================================================
// 关于域 — 版本可见 + 检查更新 + 下载安装(B1/L0 + B2/L1 + B3/L2)
// 版本信息经 app_get_version IPC 取回(编译期注入)。
// 检查更新经 app_check_update IPC(拉远端 latest.json,内建签名校验)。
// 自动检查偏好经 app_update_get_prefs / set_prefs 读写(复用 app_settings KV)。
// 下载安装经 app_download_install_update IPC;进度经 app-update-progress 事件流。
// ============================================================
const { t } = useI18n()
interface AppVersionInfo {
version: string
build_time: string
git_hash: string
}
interface UpdateInfo {
version: string
notes: string | null
date: string | null
}
interface UpdateProgressPayload {
stage: 'downloading' | 'installing'
downloaded: number
total: number | null
}
const versionInfo = ref<AppVersionInfo | null>(null)
const versionLoaded = ref(false)
const checking = ref(false)
// 检查结果描述(成功/失败/无更新)
const checkResultText = ref('')
// 更新可用信息(检测到新版时非空 → 显示提示卡)
const updateInfo = ref<UpdateInfo | null>(null)
// 自动检查偏好
const autoCheck = ref(true)
const intervalMinutes = ref(15)
const lastCheck = ref('')
// B3 下载安装状态
const installing = ref(false)
const progress = ref<UpdateProgressPayload | null>(null)
const installError = ref('')
let unlistenProgress: UnlistenFn | null = null
onMounted(async () => {
// 版本信息
try {
versionInfo.value = await settingsApi.getVersion()
} catch {
/* 静默:取版本失败不阻断关于页渲染 */
} finally {
versionLoaded.value = true
}
// 自动检查偏好
try {
const prefs = await settingsApi.getUpdatePrefs()
autoCheck.value = prefs.auto_check
intervalMinutes.value = prefs.interval_minutes
lastCheck.value = prefs.last_check
} catch {
/* 静默:偏好读取失败用默认值 */
}
// 下载进度事件订阅
try {
unlistenProgress = await listen<UpdateProgressPayload>('app-update-progress', (e) => {
progress.value = e.payload
})
} catch {
/* 静默:订阅失败不影响手动检查 */
}
})
onUnmounted(() => {
unlistenProgress?.()
unlistenProgress = null
})
// 主展示:vX.Y.Z;git_hash 非 dev 时附短 hash
const versionText = computed(() => {
const v = versionInfo.value
if (!v) return t('settings.loading')
const hash = v.git_hash && v.git_hash !== 'dev' ? ` (${v.git_hash.slice(0, 7)})` : ''
return `v${v.version}${hash}`
})
const versionTitle = computed(() => {
const v = versionInfo.value
if (!v) return ''
return `${t('settings.labelBuildTime')}: ${v.build_time}\n${t('settings.labelGitHash')}: ${v.git_hash}`
})
const lastCheckText = computed(() => lastCheck.value || t('settings.lastCheckNever'))
// 检查更新:调 app_check_update,成功则更新结果文案 + 命中新版显示提示卡并记录已提示版本
async function onCheckUpdate() {
if (checking.value) return
checking.value = true
checkResultText.value = t('settings.checkUpdateChecking')
try {
const r = await settingsApi.checkUpdate()
const now = new Date().toISOString()
lastCheck.value = now
void settingsApi.markUpdateChecked(now)
if (r.has_update && r.version) {
checkResultText.value = t('settings.checkUpdateFound', { version: r.version })
updateInfo.value = { version: r.version, notes: r.notes, date: r.date }
void settingsApi.markUpdateNotified(r.version)
} else {
checkResultText.value = t('settings.checkUpdateLatest', { version: r.current_version })
}
} catch (e) {
// 网络/解析失败:明确报错,不谎报「已是最新」
checkResultText.value = t('settings.checkUpdateFailed', { error: String(e) })
} finally {
checking.value = false
}
}
// 「稍后」:关闭提示卡(同版本已记 notified,不再自动弹)
function onLater() {
updateInfo.value = null
}
// 进度百分比(下载阶段;总大小未知时按 0 处理)
const progressPercent = computed(() => {
const p = progress.value
if (!p || p.stage !== 'downloading' || !p.total || p.total === 0) return 0
return Math.min(100, Math.round((p.downloaded / p.total) * 100))
})
// 进度文案:下载中(x% / MB) / 安装中
const progressText = computed(() => {
const p = progress.value
if (!p) return ''
if (p.stage === 'installing') return t('settings.updateInstallingStage')
if (!p.total) return t('settings.updateDownloadingUnknown')
const mb = (n: number) => (n / 1048576).toFixed(1)
return t('settings.updateDownloading', {
percent: progressPercent.value,
done: mb(p.downloaded),
total: mb(p.total),
})
})
// 「立即升级」:调 app_download_install_update,进度经事件流更新;
// 成功即自动重启(本函数不再返回后处理);失败展示错误 + 可重试。
async function onInstall() {
if (installing.value) return
installing.value = true
installError.value = ''
progress.value = null
try {
await settingsApi.downloadAndInstallUpdate()
// 成功路径:Tauri 自动重启,通常不会执行到这里
} catch (e) {
installError.value = t('settings.updateInstallFailed', { error: String(e) })
installing.value = false
progress.value = null
}
}
async function onAutoCheckChange() {
try {
await settingsApi.setUpdatePrefs(autoCheck.value, intervalMinutes.value)
} catch {
/* 静默:写失败保留内存态 */
}
}
async function onIntervalChange() {
try {
await settingsApi.setUpdatePrefs(autoCheck.value, intervalMinutes.value)
} catch {
/* 静默:写失败保留内存态 */
}
}
</script>
<style scoped>
/* 复用 settings.css 全局 .panel/.panel-header/.general-settings/.setting-row 等。
本组件仅保留版本号显示 + 检查更新按钮 + 更新提示卡的特有样式。 */
.about-version {
flex: 0 0 auto;
margin-left: auto;
font-size: 13px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text);
white-space: nowrap;
}
.about-last-check {
flex: 0 0 auto;
margin-left: auto;
font-size: 12px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
white-space: nowrap;
}
.about-check-btn {
flex: 0 0 auto;
margin-left: auto;
}
.about-check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.setting-checkbox {
flex: 0 0 auto;
margin-left: auto;
width: 16px;
height: 16px;
cursor: pointer;
}
/* 更新可用提示卡 */
.update-card {
margin: 12px 0 0;
padding: 14px 16px;
border: 0.5px solid var(--df-accent);
border-radius: var(--df-radius-lg);
background: var(--df-bg-card);
}
.update-card-head {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.update-card-title {
font-size: 13px;
font-weight: 600;
color: var(--df-accent);
}
.update-card-notes {
font-size: 12px;
color: var(--df-text-dim);
line-height: 1.5;
white-space: pre-wrap;
max-height: 160px;
overflow-y: auto;
margin-bottom: 12px;
}
.update-card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.update-card-actions .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 下载进度条 */
.update-progress {
margin-bottom: 12px;
}
.update-progress-bar {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--df-bg-hover, rgba(128, 128, 128, 0.2));
overflow: hidden;
}
.update-progress-fill {
height: 100%;
background: var(--df-accent);
transition: width 0.2s ease;
}
.update-progress-text {
margin-top: 6px;
font-size: 12px;
font-family: var(--df-font-mono, monospace);
color: var(--df-text-dim);
}
/* 安装错误提示 */
.update-error {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: var(--df-radius-md, 6px);
background: rgba(220, 53, 69, 0.12);
font-size: 12px;
color: var(--df-danger, #dc3545);
line-height: 1.5;
word-break: break-all;
}
.update-card-enter-active,
.update-card-leave-active {
transition: opacity 0.2s;
}
.update-card-enter-from,
.update-card-leave-to {
opacity: 0;
}
</style>
+1
View File
@@ -76,6 +76,7 @@ const categories: NavCategory[] = [
{ value: 'security', icon: '🔒', labelKey: 'settings.navSecurity' },
{ value: 'integrations', icon: '🔌', labelKey: 'settings.navIntegrations' },
{ value: 'advanced', icon: '⚙️', labelKey: 'settings.navAdvanced' },
{ value: 'about', icon: 'ℹ️', labelKey: 'settings.navAbout' },
]
const props = defineProps<{
+9 -1
View File
@@ -20,6 +20,7 @@ export type SettingsCategory =
| 'security'
| 'integrations'
| 'advanced'
| 'about'
/** 搜索索引项:category 所属分类 + labelKey/descKey(i18n key) + key 唯一标识 */
export interface SettingIndexEntry {
@@ -34,7 +35,7 @@ export interface SettingIndexEntry {
}
/**
* 设置项静态索引 —— 覆盖全部 6 类设置项。
* 设置项静态索引 —— 覆盖全部 7 类设置项。
* 分类映射(对齐 plan):
* appearance 外观(theme/language/aiLanguage/showTokenUsage)
* ai-model AI 模型(provider 列表 / 负载均衡池 / 模型拉取)
@@ -45,6 +46,7 @@ export interface SettingIndexEntry {
* security 安全(allowed_dirs)
* integrations 集成(飞书渠道凭证)
* advanced 高级(autoExecuteMode/logLevel + 连接)
* about 关于(版本号 / 更新检查)
*/
export const SETTINGS_INDEX: SettingIndexEntry[] = [
// ===== 外观 =====
@@ -112,4 +114,10 @@ export const SETTINGS_INDEX: SettingIndexEntry[] = [
{ category: 'advanced', key: 'approvalTimeout', labelKey: 'settings.labelApprovalTimeout', descKey: 'settings.descApprovalTimeout' },
{ category: 'advanced', key: 'fileUploadBase', labelKey: 'settings.labelFileUploadBase', descKey: 'settings.descFileUploadBase' },
{ category: 'advanced', key: 'connection', labelKey: 'settings.panelConnection', descKey: 'settings.emptyConnection' },
// ===== 关于 =====
// 版本号 + 更新检查(B1 版本更新与自动升级链路)。key 对齐 AboutSection.vue
// 渲染行的 data-setting-key,搜索「版本/更新/升级/version」命中本类。
{ category: 'about', key: 'appVersion', labelKey: 'settings.labelAppVersion', descKey: 'settings.descAppVersion' },
{ category: 'about', key: 'checkUpdate', labelKey: 'settings.labelCheckUpdate', descKey: 'settings.descCheckUpdate' },
]