新增: 应用内版本更新与自动升级全链路
后端 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:
@@ -74,6 +74,7 @@ import ApprovalDialog from './components/project/ApprovalDialog.vue'
|
||||
import ErrorBoundary from './components/ErrorBoundary.vue'
|
||||
import AppLayout from './components/layout/AppLayout.vue'
|
||||
import { startApprovalPopup } from '@/composables/ai/useApprovalPopup'
|
||||
import { startUpdateAutoCheck } from '@/composables/useUpdateCheck'
|
||||
import { aiApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -266,6 +267,11 @@ onMounted(async () => {
|
||||
console.error('[ApprovalPopup] ready 监听失败:', e)
|
||||
}
|
||||
}
|
||||
// B2 应用内升级:自动检查调度(启动后 3s 首查 + 周期检查,同版本只提示一次)。
|
||||
// 幂等(模块级单例),仅主窗口启动;auto_check=false 时内部自动跳过。
|
||||
if (route.path !== '/approval-popup') {
|
||||
startUpdateAutoCheck()
|
||||
}
|
||||
})
|
||||
|
||||
/// 一次性迁移:遍历遗留的 localStorage key,若有值则导入 appSettings(SQLite),
|
||||
|
||||
@@ -21,6 +21,52 @@ export const settingsApi = {
|
||||
return invoke<string>('get_data_dir')
|
||||
},
|
||||
|
||||
/** 获取应用版本信息(编译期注入:version/build_time/git_hash) */
|
||||
getVersion(): Promise<{ version: string; build_time: string; git_hash: string }> {
|
||||
return invoke<{ version: string; build_time: string; git_hash: string }>('app_get_version')
|
||||
},
|
||||
|
||||
/** B2 检查更新:拉远端 latest.json,返回是否有新版(网络失败抛错,不谎报已是最新) */
|
||||
checkUpdate(): Promise<{
|
||||
has_update: boolean
|
||||
version: string | null
|
||||
notes: string | null
|
||||
date: string | null
|
||||
current_version: string
|
||||
}> {
|
||||
return invoke('app_check_update')
|
||||
},
|
||||
|
||||
/** B2 读自动检查偏好 */
|
||||
getUpdatePrefs(): Promise<{
|
||||
auto_check: boolean
|
||||
interval_minutes: number
|
||||
last_check: string
|
||||
notified_version: string
|
||||
}> {
|
||||
return invoke('app_update_get_prefs')
|
||||
},
|
||||
|
||||
/** B2 写自动检查偏好 */
|
||||
setUpdatePrefs(autoCheck: boolean, intervalMinutes: number): Promise<void> {
|
||||
return invoke('app_update_set_prefs', { autoCheck, intervalMinutes })
|
||||
},
|
||||
|
||||
/** B2 记录一次检查时间戳(节流用) */
|
||||
markUpdateChecked(timestamp: string): Promise<void> {
|
||||
return invoke('app_update_mark_checked', { timestamp })
|
||||
},
|
||||
|
||||
/** B2 记录已提示版本号(同版本只提示一次) */
|
||||
markUpdateNotified(version: string): Promise<void> {
|
||||
return invoke('app_update_mark_notified', { version })
|
||||
},
|
||||
|
||||
/** B3 下载并安装更新(进度经 app-update-progress 事件流;成功即自动重启) */
|
||||
downloadAndInstallUpdate(): Promise<void> {
|
||||
return invoke('app_download_install_update')
|
||||
},
|
||||
|
||||
/** 取全部 key/value(启动时一次性拉回恢复偏好) */
|
||||
getAll(): Promise<Record<string, string>> {
|
||||
return invoke<Record<string, string>>('settings_get_all')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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<{
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// 应用内升级 — 自动检查调度(B2/L1)
|
||||
//
|
||||
// 设计:docs/02-架构设计/专项设计/版本更新与自动升级-设计-2026-10-09.md §四 L1
|
||||
//
|
||||
// 策略(C4 定稿):启动后延迟 3s 首查 → 此后按 interval_minutes(默认 15)周期查。
|
||||
// 去重:同一版本号只提示一次(KV df-update-notified-version),「稍后」后不再重复弹。
|
||||
// 只提示不自动装(安装是高风险动作,必须人点头)。
|
||||
//
|
||||
// 实现要点:
|
||||
// - 单例调度:模块级 timer,防多组件重复启动。
|
||||
// - 节流判定:读 KV last_check + interval,未到间隔跳过(防每次启动都拉)。
|
||||
// - 更新可用时广播 window 事件 'app-update-available',TopBar 徽章/AboutPanel 消费。
|
||||
// - 停用:auto_check=false 时不启动。
|
||||
|
||||
import { settingsApi } from '@/api'
|
||||
|
||||
/** 更新可用事件载荷 */
|
||||
export interface UpdateAvailablePayload {
|
||||
version: string
|
||||
notes: string | null
|
||||
date: string | null
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let started = false
|
||||
|
||||
/** 首查延迟(ms)—— 设计 C4:启动后 3 秒 */
|
||||
const FIRST_CHECK_DELAY_MS = 3000
|
||||
|
||||
/**
|
||||
* 执行一次自动检查(含节流判定)。
|
||||
* - auto_check=false → 跳过
|
||||
* - 距上次检查不足 interval → 跳过
|
||||
* - 有新版且未提示过该版本 → 广播 app-update-available
|
||||
*/
|
||||
async function runAutoCheck(): Promise<void> {
|
||||
try {
|
||||
const prefs = await settingsApi.getUpdatePrefs()
|
||||
if (!prefs.auto_check) return
|
||||
|
||||
// 节流:距上次检查不足 interval 则跳过
|
||||
if (prefs.last_check) {
|
||||
const last = Date.parse(prefs.last_check)
|
||||
if (!Number.isNaN(last)) {
|
||||
const elapsedMin = (Date.now() - last) / 60000
|
||||
if (elapsedMin < prefs.interval_minutes) return
|
||||
}
|
||||
}
|
||||
|
||||
const r = await settingsApi.checkUpdate()
|
||||
const now = new Date().toISOString()
|
||||
void settingsApi.markUpdateChecked(now)
|
||||
|
||||
if (r.has_update && r.version) {
|
||||
// 同版本只提示一次
|
||||
if (prefs.notified_version === r.version) return
|
||||
void settingsApi.markUpdateNotified(r.version)
|
||||
const payload: UpdateAvailablePayload = {
|
||||
version: r.version,
|
||||
notes: r.notes,
|
||||
date: r.date,
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('app-update-available', { detail: payload }))
|
||||
}
|
||||
} catch {
|
||||
// 网络失败静默:自动检查不打扰用户(手动检查才报错)
|
||||
}
|
||||
}
|
||||
|
||||
/** 调度下一次检查(按 interval_minutes) */
|
||||
async function scheduleNext(): Promise<void> {
|
||||
let intervalMin = 15
|
||||
try {
|
||||
const prefs = await settingsApi.getUpdatePrefs()
|
||||
intervalMin = prefs.interval_minutes
|
||||
} catch {
|
||||
/* 用默认 15 */
|
||||
}
|
||||
timer = setTimeout(async () => {
|
||||
await runAutoCheck()
|
||||
await scheduleNext()
|
||||
}, Math.max(1, intervalMin) * 60_000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动自动检查调度(幂等:重复调用只启动一次)。
|
||||
* 在 App.vue onMounted 调用。
|
||||
*/
|
||||
export function startUpdateAutoCheck(): void {
|
||||
if (started) return
|
||||
started = true
|
||||
timer = setTimeout(async () => {
|
||||
await runAutoCheck()
|
||||
await scheduleNext()
|
||||
}, FIRST_CHECK_DELAY_MS)
|
||||
}
|
||||
|
||||
/** 停止调度(卸载/测试用) */
|
||||
export function stopUpdateAutoCheck(): void {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
started = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 偏好变更后重置调度(用户在关于页开关自动检查/改间隔后调用)。
|
||||
* 关 → 停;开 → 立即重排。
|
||||
*/
|
||||
export async function resetUpdateAutoCheck(): Promise<void> {
|
||||
stopUpdateAutoCheck()
|
||||
try {
|
||||
const prefs = await settingsApi.getUpdatePrefs()
|
||||
if (prefs.auto_check) startUpdateAutoCheck()
|
||||
} catch {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
navSecurity: 'Security',
|
||||
navIntegrations: 'Integrations',
|
||||
navAdvanced: 'Advanced',
|
||||
navAbout: 'About',
|
||||
|
||||
// ===== Settings search (phase 4) =====
|
||||
searchPlaceholder: 'Search settings…',
|
||||
@@ -132,6 +133,42 @@ export default {
|
||||
sectionAppearance: '🎨 Appearance',
|
||||
sectionPerformance: '⚡ Performance',
|
||||
sectionAdvanced: '⚙️ Advanced',
|
||||
|
||||
// ===== About (B1/L0 version governance: version visibility + update entry) =====
|
||||
sectionAbout: 'ℹ️ About DevFlow',
|
||||
labelAppVersion: 'Current version',
|
||||
descAppVersion: 'DevFlow desktop app version (injected at compile time, same source as the installer)',
|
||||
labelCheckUpdate: 'Check for updates',
|
||||
descCheckUpdate: 'Fetch the latest version info from the release source; a new version prompts download & install (wired in B2)',
|
||||
checkUpdateAction: 'Check for updates',
|
||||
checkUpdateChecking: 'Checking…',
|
||||
checkUpdateNotReady: 'Check-for-updates is coming soon',
|
||||
checkUpdateFound: 'New version v{version} available',
|
||||
checkUpdateLatest: 'Already up to date (v{version})',
|
||||
checkUpdateFailed: 'Check failed: {error}',
|
||||
labelAutoCheckUpdate: 'Auto check for updates',
|
||||
descAutoCheckUpdate: 'Automatically check for new versions on startup (notify only, never auto-install)',
|
||||
labelCheckInterval: 'Check interval',
|
||||
descCheckInterval: 'Interval between automatic checks',
|
||||
interval5m: '5 minutes',
|
||||
interval15m: '15 minutes',
|
||||
interval30m: '30 minutes',
|
||||
interval60m: '60 minutes',
|
||||
labelLastCheck: 'Last checked',
|
||||
lastCheckNever: 'Never checked',
|
||||
updateAvailableTitle: '🎉 New version v{version} available',
|
||||
updateBadgeTitle: 'New version available, click to view',
|
||||
updateBadgeText: 'New v{version}',
|
||||
updateLater: 'Later',
|
||||
updateNow: 'Update now',
|
||||
updateInstalling: 'Updating…',
|
||||
updateInstallingStage: 'Installing, the app will restart shortly…',
|
||||
updateDownloading: 'Downloading {percent}% ({done} / {total} MB)',
|
||||
updateDownloadingUnknown: 'Downloading…',
|
||||
updateInstallFailed: 'Update failed: {error}',
|
||||
updateNotReady: 'Download & install is coming soon',
|
||||
labelBuildTime: 'Build time',
|
||||
labelGitHash: 'Git commit',
|
||||
labelTheme: 'Theme',
|
||||
descTheme: 'Dark / light',
|
||||
themeDark: '🌙 Dark',
|
||||
@@ -267,6 +304,7 @@ export default {
|
||||
labelFeishuWhitelist: 'open_id whitelist',
|
||||
descFeishuWhitelist: 'Comma-separated open_id list; leave empty to allow all users',
|
||||
phFeishuWhitelist: 'ou_xxx, ou_yyy',
|
||||
hintFeishuWhitelist: 'How to get an open_id: after enabling, have the member send any message to the bot; the bot replies with their open_id. Paste it above to allow them (leave empty to allow everyone).',
|
||||
feishuSave: 'Save',
|
||||
feishuSaveOk: 'Feishu integration settings saved',
|
||||
// Submit guard: when enabled=true, either of id/secret empty blocks the write
|
||||
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
navSecurity: '安全',
|
||||
navIntegrations: '集成',
|
||||
navAdvanced: '高级',
|
||||
navAbout: '关于',
|
||||
|
||||
// ===== 设置搜索(阶段4) =====
|
||||
searchPlaceholder: '搜索设置项…',
|
||||
@@ -131,6 +132,42 @@ export default {
|
||||
sectionAppearance: '🎨 外观',
|
||||
sectionPerformance: '⚡ 性能',
|
||||
sectionAdvanced: '⚙️ 高级',
|
||||
|
||||
// ===== 关于(B1/L0 版本治理:版本可见 + 更新入口) =====
|
||||
sectionAbout: 'ℹ️ 关于 DevFlow',
|
||||
labelAppVersion: '当前版本',
|
||||
descAppVersion: 'DevFlow 桌面应用版本号(编译期注入,与安装包同源)',
|
||||
labelCheckUpdate: '检查更新',
|
||||
descCheckUpdate: '从发布源拉取最新版本信息;有新版会提示下载安装(B2 接入)',
|
||||
checkUpdateAction: '检查更新',
|
||||
checkUpdateChecking: '检查中…',
|
||||
checkUpdateNotReady: '检查更新功能即将上线',
|
||||
checkUpdateFound: '发现新版本 v{version}',
|
||||
checkUpdateLatest: '已是最新版本(v{version})',
|
||||
checkUpdateFailed: '检查失败:{error}',
|
||||
labelAutoCheckUpdate: '自动检查更新',
|
||||
descAutoCheckUpdate: '启动后自动检查是否有新版本(仅提示,不自动安装)',
|
||||
labelCheckInterval: '检查间隔',
|
||||
descCheckInterval: '自动检查的时间间隔',
|
||||
interval5m: '5 分钟',
|
||||
interval15m: '15 分钟',
|
||||
interval30m: '30 分钟',
|
||||
interval60m: '60 分钟',
|
||||
labelLastCheck: '上次检查',
|
||||
lastCheckNever: '从未检查',
|
||||
updateAvailableTitle: '🎉 发现新版本 v{version}',
|
||||
updateBadgeTitle: '发现新版本,点击查看',
|
||||
updateBadgeText: '新版本 v{version}',
|
||||
updateLater: '稍后',
|
||||
updateNow: '立即升级',
|
||||
updateInstalling: '升级中…',
|
||||
updateInstallingStage: '正在安装,应用即将重启…',
|
||||
updateDownloading: '下载中 {percent}%({done} / {total} MB)',
|
||||
updateDownloadingUnknown: '下载中…',
|
||||
updateInstallFailed: '升级失败:{error}',
|
||||
updateNotReady: '下载安装功能即将上线',
|
||||
labelBuildTime: '构建时间',
|
||||
labelGitHash: 'Git 提交',
|
||||
labelTheme: '主题',
|
||||
descTheme: '深色 / 浅色模式',
|
||||
themeDark: '🌙 深色模式',
|
||||
@@ -266,6 +303,7 @@ export default {
|
||||
labelFeishuWhitelist: 'open_id 白名单',
|
||||
descFeishuWhitelist: '逗号分隔的 open_id,留空表示放行所有用户',
|
||||
phFeishuWhitelist: 'ou_xxx, ou_yyy',
|
||||
hintFeishuWhitelist: '如何获取 open_id:启用集成后,让成员向机器人发任意消息,机器人会回复其 open_id;复制填入上方即可(留空则放行所有人)。',
|
||||
feishuSave: '保存',
|
||||
feishuSaveOk: '飞书集成配置已保存',
|
||||
// 提交校验:enabled=true 时 id/secret 任一为空都拦在写库前
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export { useProjectStore } from './project'
|
||||
|
||||
export { useKnowledgeStore } from './knowledge'
|
||||
|
||||
|
||||
export { useAppSettingsStore } from './appSettings'
|
||||
|
||||
export { useAiStore } from './ai'
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
@toast="showToast"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 关于:版本号展示 + 更新检查入口(B1/L0 版本治理) -->
|
||||
<AboutPanel v-else-if="activeCategory === 'about'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,6 +113,7 @@ import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
|
||||
import FeishuPanel from '@/components/settings/FeishuPanel.vue'
|
||||
import UclawPanel from '@/components/settings/UclawPanel.vue'
|
||||
import UnattendedPanel from '@/components/settings/UnattendedPanel.vue'
|
||||
import AboutPanel from '@/components/settings/AboutPanel.vue'
|
||||
// SettingsImportExport 已移除(价值低,占用 UI 空间;跨设备同步建议直接复制 SQLite)
|
||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||
import { aiApi } from '@/api'
|
||||
|
||||
Reference in New Issue
Block a user