mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
Merge remote-tracking branch 'origin/pr/544'
This commit is contained in:
@@ -8,9 +8,9 @@
|
||||
"build": "vite build",
|
||||
"build:with-typecheck": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest",
|
||||
"test:ui": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest --ui",
|
||||
"test:run": "NODE_OPTIONS='--experimental-require-module --disable-warning=ExperimentalWarning' vitest run",
|
||||
"test": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs",
|
||||
"test:ui": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs --ui",
|
||||
"test:run": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run",
|
||||
"lint": "eslint . --fix",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"version": "git describe --tags --always"
|
||||
|
||||
@@ -371,12 +371,72 @@ export interface CheckUpdateResponse {
|
||||
current_version: string
|
||||
latest_version: string | null
|
||||
has_update: boolean
|
||||
updatable: boolean
|
||||
update_blocker: string | null
|
||||
release_url: string | null
|
||||
release_notes: string | null
|
||||
published_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SystemUpdateCapabilityResponse {
|
||||
supported: boolean
|
||||
build_type: string
|
||||
enabled: boolean
|
||||
rollback_available: boolean
|
||||
task_status: string
|
||||
task_error: string | null
|
||||
install_root?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UpdateTaskStatusResponse {
|
||||
phase: string
|
||||
error: string | null
|
||||
output: string | null
|
||||
progress_label?: string | null
|
||||
downloaded_bytes?: number | null
|
||||
total_bytes?: number | null
|
||||
progress_percent?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateHistoryEntry {
|
||||
timestamp: string
|
||||
operation: string
|
||||
success: boolean
|
||||
error: string | null
|
||||
output_tail: string | null
|
||||
}
|
||||
|
||||
export interface UpdateHistoryResponse {
|
||||
entries: UpdateHistoryEntry[]
|
||||
}
|
||||
|
||||
export interface ApplySystemUpdateResponse {
|
||||
message: string
|
||||
started: boolean
|
||||
need_restart: boolean
|
||||
}
|
||||
|
||||
export interface ReleaseEntry {
|
||||
version: string
|
||||
release_url: string | null
|
||||
release_notes: string | null
|
||||
published_at: string | null
|
||||
tarball_url?: string | null
|
||||
sha256sums_url?: string | null
|
||||
is_current: boolean
|
||||
is_newer: boolean
|
||||
updatable: boolean
|
||||
update_blocker: string | null
|
||||
}
|
||||
|
||||
export interface ReleasesListResponse {
|
||||
current_version: string
|
||||
releases: ReleaseEntry[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// LDAP 配置响应
|
||||
export interface LdapConfigResponse {
|
||||
server_url: string | null
|
||||
@@ -995,9 +1055,67 @@ export const adminApi = {
|
||||
},
|
||||
|
||||
// 检查系统更新
|
||||
async checkUpdate(): Promise<CheckUpdateResponse> {
|
||||
async checkUpdate(force = false): Promise<CheckUpdateResponse> {
|
||||
const response = await apiClient.get<CheckUpdateResponse>(
|
||||
'/api/admin/system/check-update'
|
||||
'/api/admin/system/check-update',
|
||||
force ? { params: { force: 'true' } } : undefined
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getSystemReleases(force = false): Promise<ReleasesListResponse> {
|
||||
const response = await apiClient.get<ReleasesListResponse>(
|
||||
'/api/admin/system/releases',
|
||||
force ? { params: { force: 'true' } } : undefined
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取一键更新能力
|
||||
async getSystemUpdateCapability(): Promise<SystemUpdateCapabilityResponse> {
|
||||
const response = await apiClient.get<SystemUpdateCapabilityResponse>(
|
||||
'/api/admin/system/update-capability'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 准备系统一键更新(下载并校验 release 包)
|
||||
async prepareSystemUpdate(version?: string | null): Promise<ApplySystemUpdateResponse> {
|
||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||
'/api/admin/system/prepare-update',
|
||||
version ? { version } : undefined
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 触发系统一键重启(切换 release 并退出等待进程管理器拉起)
|
||||
async applySystemUpdate(version?: string | null): Promise<ApplySystemUpdateResponse> {
|
||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||
'/api/admin/system/apply-update',
|
||||
version ? { version } : undefined
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 回滚到上一个版本
|
||||
async rollbackSystemUpdate(): Promise<ApplySystemUpdateResponse> {
|
||||
const response = await apiClient.post<ApplySystemUpdateResponse>(
|
||||
'/api/admin/system/rollback'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 查询更新任务状态
|
||||
async getUpdateStatus(): Promise<UpdateTaskStatusResponse> {
|
||||
const response = await apiClient.get<UpdateTaskStatusResponse>(
|
||||
'/api/admin/system/update-status'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getUpdateHistory(): Promise<UpdateHistoryResponse> {
|
||||
const response = await apiClient.get<UpdateHistoryResponse>(
|
||||
'/api/admin/system/update-history'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="isOpen"
|
||||
size="md"
|
||||
size="lg"
|
||||
title=""
|
||||
>
|
||||
<div class="flex flex-col items-center text-center py-2">
|
||||
@@ -11,66 +11,163 @@
|
||||
class-name="text-primary"
|
||||
/>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||
发现新版本
|
||||
</h2>
|
||||
|
||||
<!-- Version Info -->
|
||||
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
最新版本
|
||||
<!-- Reconnecting State -->
|
||||
<template v-if="updatePhase === 'reconnecting'">
|
||||
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||
正在重启服务
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground max-w-xs mt-2 mb-2">
|
||||
服务正在切换版本并重启,请稍候...
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
|
||||
{{ formatDisplayVersion(latestVersion) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-primary mt-2 mb-4">
|
||||
<svg
|
||||
class="animate-spin h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">
|
||||
{{ reconnectMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Release Notes -->
|
||||
<div
|
||||
v-if="releaseNotes"
|
||||
class="w-full mt-3 mb-4"
|
||||
>
|
||||
<!-- Normal Update State -->
|
||||
<template v-else>
|
||||
<h2 class="text-xl font-semibold text-foreground mt-4 mb-2">
|
||||
{{ dialogTitleText }}
|
||||
</h2>
|
||||
|
||||
<!-- Version Info -->
|
||||
<div class="mx-auto mb-2 w-full max-w-sm rounded-lg bg-muted/20 px-4 py-3 text-center">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ versionLabelText }}
|
||||
</p>
|
||||
<p class="mt-1 break-all font-mono text-base font-semibold text-primary">
|
||||
{{ formatDisplayVersion(latestVersion) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Release Notes -->
|
||||
<div
|
||||
v-if="publishedAt"
|
||||
class="text-left text-xs text-muted-foreground mb-2"
|
||||
v-if="displayReleaseNotes"
|
||||
class="w-full mt-3 mb-4"
|
||||
>
|
||||
发布于 {{ formattedPublishedAt }}
|
||||
<div
|
||||
v-if="publishedAt"
|
||||
class="mb-2 text-left text-xs text-muted-foreground"
|
||||
>
|
||||
发布于 {{ formattedPublishedAt }}
|
||||
</div>
|
||||
<div class="mb-2 text-left text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground/80">
|
||||
更新内容
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="max-h-64 w-full overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-left text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
|
||||
v-html="renderedReleaseNotes"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
<div class="text-left text-xs font-medium text-muted-foreground mb-2">
|
||||
更新内容
|
||||
</div>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="w-full max-h-48 overflow-y-auto rounded-lg bg-muted/50 p-3 text-left text-sm text-foreground/80 prose prose-sm dark:prose-invert prose-p:my-1 prose-ul:my-1 prose-li:my-0"
|
||||
v-html="renderedReleaseNotes"
|
||||
/>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
|
||||
<!-- Description (fallback when no release notes) -->
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
|
||||
>
|
||||
新版本已发布,建议更新以获得最新功能和安全修复
|
||||
</p>
|
||||
<!-- Description (fallback when no release notes) -->
|
||||
<p
|
||||
v-else
|
||||
class="text-sm text-muted-foreground max-w-xs mt-2 mb-4"
|
||||
>
|
||||
{{ fallbackDescriptionText }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="updatePhase === 'restart'"
|
||||
class="mt-1 text-xs text-primary"
|
||||
>
|
||||
更新包已下载,点击"立即重启"完成安装
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="updating && updatePhase === 'download'"
|
||||
class="mt-3 w-full max-w-sm"
|
||||
>
|
||||
<div class="mb-1.5 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span class="truncate">{{ downloadProgressText }}</span>
|
||||
<span
|
||||
v-if="downloadProgressPercent !== null"
|
||||
class="shrink-0 font-mono text-primary"
|
||||
>
|
||||
{{ downloadProgressPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
:style="{ width: progressBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source Build Hint -->
|
||||
<p
|
||||
v-if="!canApplyUpdate"
|
||||
class="mt-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ updateBlockerText }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex w-full gap-3">
|
||||
<div
|
||||
v-if="updatePhase !== 'reconnecting'"
|
||||
class="flex w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="updating || rollingBack"
|
||||
@click="handleLater"
|
||||
>
|
||||
稍后提醒
|
||||
</Button>
|
||||
<Button
|
||||
v-if="rollbackAvailable"
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="updating || rollingBack"
|
||||
@click="handleRollback"
|
||||
>
|
||||
{{ rollingBack ? '回滚中...' : '回滚上一版本' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-else
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
:disabled="updating || rollingBack"
|
||||
@click="handleViewRelease"
|
||||
>
|
||||
查看更新
|
||||
{{ releaseLinkLabelText }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="updateSupported"
|
||||
class="flex-1"
|
||||
:disabled="updating || rollingBack || !canApplyUpdate"
|
||||
@click="handleApplyUpdate"
|
||||
>
|
||||
{{ actionButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -83,8 +180,11 @@ import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import HeaderLogo from '@/components/HeaderLogo.vue'
|
||||
import { formatDisplayVersion } from '@/utils/version'
|
||||
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -93,13 +193,63 @@ const props = defineProps<{
|
||||
releaseUrl: string | null
|
||||
releaseNotes: string | null
|
||||
publishedAt: string | null
|
||||
dialogTitle?: string
|
||||
versionLabel?: string
|
||||
releaseLinkLabel?: string
|
||||
updatePhase?: 'download' | 'restart' | 'reconnecting'
|
||||
updating?: boolean
|
||||
updateSupported?: boolean
|
||||
updatable?: boolean
|
||||
updateBlocker?: string | null
|
||||
reconnectMessage?: string
|
||||
rollbackAvailable?: boolean
|
||||
rollingBack?: boolean
|
||||
downloadProgressText?: string | null
|
||||
downloadProgressPercent?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
applyUpdate: []
|
||||
rollback: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(props.modelValue)
|
||||
const updating = computed(() => props.updating ?? false)
|
||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||
const updateSupported = computed(() => props.updateSupported ?? true)
|
||||
const updatable = computed(() => props.updatable ?? true)
|
||||
const canApplyUpdate = computed(() => updateSupported.value && updatable.value)
|
||||
const updateBlockerText = computed(() => {
|
||||
if (!updateSupported.value) return props.updateBlocker || SOURCE_BUILD_UPDATE_HINT
|
||||
return props.updateBlocker || '当前版本暂不支持在线更新'
|
||||
})
|
||||
const reconnectMessage = computed(() => props.reconnectMessage ?? '等待服务恢复...')
|
||||
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
|
||||
const rollingBack = computed(() => props.rollingBack ?? false)
|
||||
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
|
||||
const dialogTitleText = computed(() => props.dialogTitle ?? '发现新版本')
|
||||
const versionLabelText = computed(() => props.versionLabel ?? '最新版本')
|
||||
const releaseLinkLabelText = computed(() => props.releaseLinkLabel ?? '查看发布')
|
||||
const fallbackDescriptionText = computed(() => {
|
||||
if (!canApplyUpdate.value) return updateBlockerText.value
|
||||
return '新版本已发布,建议更新以获得最新功能和安全修复'
|
||||
})
|
||||
const downloadProgressPercent = computed(() => {
|
||||
const value = props.downloadProgressPercent
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.max(0, Math.min(100, Math.round(value)))
|
||||
: null
|
||||
})
|
||||
const progressBarWidth = computed(() => {
|
||||
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
|
||||
})
|
||||
const actionButtonLabel = computed(() => {
|
||||
if (updating.value) {
|
||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||
}
|
||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
@@ -109,7 +259,6 @@ watch(isOpen, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 格式化发布时间
|
||||
const formattedPublishedAt = computed(() => {
|
||||
if (!props.publishedAt) return ''
|
||||
try {
|
||||
@@ -124,15 +273,20 @@ const formattedPublishedAt = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 渲染 Markdown 格式的 Release Notes(使用 DOMPurify 防止 XSS)
|
||||
const displayReleaseNotes = computed(() => {
|
||||
return normalizeReleaseNotesForDisplay(props.releaseNotes)
|
||||
})
|
||||
|
||||
const renderedReleaseNotes = computed(() => {
|
||||
if (!props.releaseNotes) return ''
|
||||
if (!displayReleaseNotes.value) return ''
|
||||
try {
|
||||
const html = marked.parse(props.releaseNotes, { async: false }) as string
|
||||
return DOMPurify.sanitize(html)
|
||||
const html = marked.parse(displayReleaseNotes.value, {
|
||||
async: false,
|
||||
breaks: true
|
||||
}) as string
|
||||
return sanitizeMarkdown(html)
|
||||
} catch {
|
||||
// 如果 markdown 解析失败,返回原始文本(转义 HTML)
|
||||
return props.releaseNotes
|
||||
return displayReleaseNotes.value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
@@ -141,11 +295,10 @@ const renderedReleaseNotes = computed(() => {
|
||||
})
|
||||
|
||||
function handleLater() {
|
||||
// 记录忽略的版本,24小时内不再提醒
|
||||
const ignoreKey = 'aether_update_ignore'
|
||||
const ignoreData = {
|
||||
version: props.latestVersion,
|
||||
until: Date.now() + 24 * 60 * 60 * 1000 // 24小时
|
||||
until: Date.now() + 24 * 60 * 60 * 1000
|
||||
}
|
||||
localStorage.setItem(ignoreKey, JSON.stringify(ignoreData))
|
||||
isOpen.value = false
|
||||
@@ -157,4 +310,13 @@ function handleViewRelease() {
|
||||
}
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleApplyUpdate() {
|
||||
if (!canApplyUpdate.value) return
|
||||
emit('applyUpdate')
|
||||
}
|
||||
|
||||
function handleRollback() {
|
||||
emit('rollback')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,9 +9,14 @@
|
||||
aria-label="版本信息"
|
||||
>
|
||||
<Info
|
||||
v-if="!isReconnecting"
|
||||
class="h-4 w-4"
|
||||
:class="loading ? 'animate-pulse' : ''"
|
||||
/>
|
||||
<RefreshCw
|
||||
v-else
|
||||
class="h-4 w-4 animate-spin"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -69,12 +74,52 @@
|
||||
检查更新失败:{{ status.error }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p
|
||||
v-if="status?.has_update && !canApplyUpdate"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ updateBlockerText }}
|
||||
</p>
|
||||
|
||||
<!-- Reconnecting / busy banner -->
|
||||
<div
|
||||
v-if="isReconnecting"
|
||||
class="flex items-center justify-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-primary"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5 animate-spin" />
|
||||
<span class="text-xs font-medium">服务重启中,请稍候...</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isDownloadingUpdate"
|
||||
class="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 text-xs text-primary">
|
||||
<span class="truncate">{{ downloadProgressText }}</span>
|
||||
<span
|
||||
v-if="downloadProgressPercent !== null"
|
||||
class="shrink-0 font-mono"
|
||||
>
|
||||
{{ downloadProgressPercent }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-primary/15">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-300"
|
||||
:style="{ width: progressBarWidth }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:disabled="loading"
|
||||
:disabled="isBusy || loading"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -84,45 +129,289 @@
|
||||
重新检查
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status?.has_update && status.release_url"
|
||||
v-if="rollbackAvailable"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:disabled="isBusy"
|
||||
@click="handleRollback"
|
||||
>
|
||||
{{ rollingBack ? '回滚中...' : '回滚' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status?.has_update && status.release_url && !rollbackAvailable"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
@click="handleOpenRelease"
|
||||
>
|
||||
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||
查看更新
|
||||
{{ releaseButtonLabel }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="status?.has_update && canApplyUpdate"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
:disabled="isBusy"
|
||||
@click="handleApplyUpdate"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-3.5 w-3.5"
|
||||
:class="updating ? 'animate-spin' : ''"
|
||||
/>
|
||||
{{ actionButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Releases List -->
|
||||
<div v-if="!isReconnecting">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition"
|
||||
@click="toggleReleases"
|
||||
>
|
||||
<ChevronRight
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="showReleases ? 'rotate-90' : ''"
|
||||
/>
|
||||
历史版本
|
||||
<span
|
||||
v-if="releases.length > 0"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
>({{ releases.length }})</span>
|
||||
<RefreshCw
|
||||
v-if="loadingReleases"
|
||||
class="ml-auto h-3 w-3 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="showReleases"
|
||||
class="mt-2 max-h-48 space-y-1 overflow-y-auto"
|
||||
>
|
||||
<p
|
||||
v-if="releasesError"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ releasesError }}
|
||||
</p>
|
||||
<button
|
||||
v-for="release in releases"
|
||||
:key="release.version"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md border border-border/40 px-2.5 py-1.5 text-left text-xs transition hover:border-primary/30 hover:bg-primary/5"
|
||||
:class="release.is_current ? 'bg-primary/5 border-primary/20' : 'bg-muted/10'"
|
||||
@click="openReleaseDetails(release)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="break-all font-mono font-medium text-foreground">
|
||||
{{ formatDisplayVersion(release.version) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="release.is_current"
|
||||
class="shrink-0 rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary"
|
||||
>当前</span>
|
||||
<span
|
||||
v-else-if="release.is_newer"
|
||||
class="shrink-0 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>新</span>
|
||||
<span
|
||||
v-if="release.is_newer && release.updatable === false"
|
||||
class="shrink-0 rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400"
|
||||
>不可在线更新</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="release.published_at"
|
||||
class="mt-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ formatDate(release.published_at) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="release.update_blocker"
|
||||
class="mt-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ release.update_blocker }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="ml-2 shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
详情
|
||||
</span>
|
||||
</button>
|
||||
<p
|
||||
v-if="!loadingReleases && releases.length === 0 && !releasesError"
|
||||
class="py-2 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无版本信息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Dialog
|
||||
v-model="showReleaseDetails"
|
||||
size="xl"
|
||||
:title="selectedReleaseTitle"
|
||||
:description="selectedReleaseDescription"
|
||||
>
|
||||
<div
|
||||
v-if="selectedRelease"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border border-border/60 bg-muted/20 px-2 py-0.5 font-mono text-[11px] text-foreground">
|
||||
{{ formatDisplayVersion(selectedRelease.version) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedRelease.is_current"
|
||||
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
当前运行版本
|
||||
</span>
|
||||
<span
|
||||
v-else-if="selectedRelease.is_newer"
|
||||
class="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
可升级版本
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
历史版本
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="selectedReleaseHelpText"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ selectedReleaseHelpText }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="selectedReleaseDisplayNotes"
|
||||
class="max-h-[26rem] overflow-y-auto rounded-xl border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6 text-foreground/90 shadow-inner shadow-black/[0.02] max-w-none prose prose-sm dark:prose-invert prose-headings:mb-2 prose-headings:mt-4 prose-headings:font-semibold prose-headings:text-foreground prose-h3:text-sm prose-p:my-2 prose-ul:my-2 prose-ul:list-disc prose-ul:pl-5 prose-li:my-1 prose-li:marker:text-primary prose-a:text-primary prose-strong:text-foreground prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5"
|
||||
v-html="selectedReleaseNotesHtml"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="rounded-lg bg-muted/30 px-3 py-4 text-sm text-muted-foreground"
|
||||
>
|
||||
这个版本没有附带更新说明。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="showReleaseDetails = false"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
v-if="selectedRelease?.release_url"
|
||||
variant="outline"
|
||||
@click="handleOpenSelectedReleasePage"
|
||||
>
|
||||
<ExternalLink class="mr-2 h-3.5 w-3.5" />
|
||||
查看标签页
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUseSelectedRelease"
|
||||
:disabled="isBusy"
|
||||
@click="handleUseSelectedRelease"
|
||||
>
|
||||
<RefreshCw
|
||||
class="mr-2 h-3.5 w-3.5"
|
||||
:class="isBusy ? 'animate-spin' : ''"
|
||||
/>
|
||||
{{ selectedReleaseActionLabel }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { CheckUpdateResponse } from '@/api/admin'
|
||||
import { Button, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
|
||||
import type { CheckUpdateResponse, ReleaseEntry } from '@/api/admin'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { Button, Dialog, Popover, PopoverContent, PopoverTrigger } from '@/components/ui'
|
||||
import { normalizeReleaseNotesForDisplay } from '@/utils/releaseNotes'
|
||||
import { formatDisplayVersion } from '@/utils/version'
|
||||
import { describeUpdateStatus } from '@/utils/updateStatus'
|
||||
import { ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
import { marked } from 'marked'
|
||||
import { ChevronRight, ExternalLink, Info, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
|
||||
const props = defineProps<{
|
||||
status: CheckUpdateResponse | null
|
||||
loading?: boolean
|
||||
updating?: boolean
|
||||
updatePhase?: 'download' | 'restart' | 'reconnecting'
|
||||
updateSupported?: boolean
|
||||
rollbackAvailable?: boolean
|
||||
rollingBack?: boolean
|
||||
downloadProgressText?: string | null
|
||||
downloadProgressPercent?: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
openRelease: []
|
||||
applyUpdate: []
|
||||
previewRelease: [release: ReleaseEntry]
|
||||
rollback: []
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const showReleases = ref(false)
|
||||
const showReleaseDetails = ref(false)
|
||||
const loadingReleases = ref(false)
|
||||
const releases = ref<ReleaseEntry[]>([])
|
||||
const releasesError = ref<string | null>(null)
|
||||
const selectedRelease = ref<ReleaseEntry | null>(null)
|
||||
let releasesFetched = false
|
||||
|
||||
const loading = computed(() => props.loading ?? false)
|
||||
const updating = computed(() => props.updating ?? false)
|
||||
const updatePhase = computed(() => props.updatePhase ?? 'download')
|
||||
const updateSupported = computed(() => props.updateSupported ?? true)
|
||||
const rollbackAvailable = computed(() => props.rollbackAvailable ?? false)
|
||||
const rollingBack = computed(() => props.rollingBack ?? false)
|
||||
const isReconnecting = computed(() => updatePhase.value === 'reconnecting')
|
||||
const isDownloadingUpdate = computed(() => updating.value && updatePhase.value === 'download')
|
||||
const isBusy = computed(() => updating.value || rollingBack.value || isReconnecting.value)
|
||||
const canApplyUpdate = computed(() => updateSupported.value && props.status?.updatable !== false)
|
||||
const downloadProgressText = computed(() => props.downloadProgressText || '正在下载更新包...')
|
||||
const downloadProgressPercent = computed(() => {
|
||||
const value = props.downloadProgressPercent
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.max(0, Math.min(100, Math.round(value)))
|
||||
: null
|
||||
})
|
||||
const progressBarWidth = computed(() => {
|
||||
return downloadProgressPercent.value === null ? '35%' : `${downloadProgressPercent.value}%`
|
||||
})
|
||||
const updateBlockerText = computed(() => {
|
||||
if (!updateSupported.value) {
|
||||
return props.status?.update_blocker || SOURCE_BUILD_UPDATE_HINT
|
||||
}
|
||||
return props.status?.update_blocker || '当前版本暂不支持在线更新'
|
||||
})
|
||||
const releaseButtonLabel = computed(() => updateSupported.value ? '查看更新' : '查看发布')
|
||||
const buttonClass = computed(() => {
|
||||
const classes = []
|
||||
|
||||
if (isReconnecting.value) {
|
||||
classes.push('bg-primary/10 text-primary animate-pulse')
|
||||
return classes
|
||||
}
|
||||
|
||||
if (isOpen.value) {
|
||||
classes.push('bg-muted/50')
|
||||
} else {
|
||||
@@ -139,7 +428,12 @@ const buttonClass = computed(() => {
|
||||
|
||||
return classes
|
||||
})
|
||||
const statusLabel = computed(() => describeUpdateStatus(props.status))
|
||||
const statusLabel = computed(() => {
|
||||
if (isReconnecting.value) return '重启中'
|
||||
if (rollingBack.value) return '回滚中'
|
||||
if (updating.value) return '更新中'
|
||||
return describeUpdateStatus(props.status)
|
||||
})
|
||||
const currentVersionLabel = computed(() => {
|
||||
return props.status?.current_version
|
||||
? formatDisplayVersion(props.status.current_version)
|
||||
@@ -151,17 +445,123 @@ const latestVersionLabel = computed(() => {
|
||||
: ''
|
||||
})
|
||||
const statusPillClass = computed(() => {
|
||||
if (isReconnecting.value || updating.value || rollingBack.value) {
|
||||
return 'border-primary/20 bg-primary/10 text-primary'
|
||||
}
|
||||
if (!props.status) return 'border-border/60 bg-background/70 text-muted-foreground'
|
||||
if (props.status.has_update) return 'border-primary/20 bg-primary/10 text-primary'
|
||||
if (props.status.error) return 'border-destructive/20 bg-destructive/10 text-destructive'
|
||||
return 'border-border/60 bg-background/70 text-muted-foreground'
|
||||
})
|
||||
const buttonTitle = computed(() => {
|
||||
if (isReconnecting.value) return '服务重启中...'
|
||||
if (!props.status) return '版本信息'
|
||||
return `版本信息:${statusLabel.value}`
|
||||
})
|
||||
const actionButtonLabel = computed(() => {
|
||||
if (updating.value) {
|
||||
return updatePhase.value === 'restart' ? '重启中...' : '下载中...'
|
||||
}
|
||||
return updatePhase.value === 'restart' ? '立即重启' : '立即更新'
|
||||
})
|
||||
const selectedReleaseTitle = computed(() => {
|
||||
return selectedRelease.value
|
||||
? `版本详情 · ${formatDisplayVersion(selectedRelease.value.version)}`
|
||||
: '版本详情'
|
||||
})
|
||||
const selectedReleaseDescription = computed(() => {
|
||||
return selectedRelease.value?.published_at
|
||||
? `发布于 ${formatDate(selectedRelease.value.published_at)}`
|
||||
: '查看该版本的发布说明'
|
||||
})
|
||||
const canUseSelectedRelease = computed(() => {
|
||||
return !!selectedRelease.value &&
|
||||
!selectedRelease.value.is_current &&
|
||||
selectedRelease.value.updatable !== false &&
|
||||
updateSupported.value
|
||||
})
|
||||
const selectedReleaseActionLabel = computed(() => {
|
||||
if (!selectedRelease.value) return '切换到此版本'
|
||||
return selectedRelease.value.is_newer ? '更新到此版本' : '切换到此版本'
|
||||
})
|
||||
const selectedReleaseHelpText = computed(() => {
|
||||
if (!selectedRelease.value) return ''
|
||||
if (selectedRelease.value.is_current) return '当前正在运行这个版本。'
|
||||
if (!updateSupported.value) {
|
||||
return selectedRelease.value.update_blocker || SOURCE_BUILD_RELEASE_HINT
|
||||
}
|
||||
if (selectedRelease.value.update_blocker) return selectedRelease.value.update_blocker
|
||||
return selectedRelease.value.is_newer
|
||||
? '将这个版本作为在线更新目标。'
|
||||
: '将切换到这个历史版本。'
|
||||
})
|
||||
const selectedReleaseDisplayNotes = computed(() => {
|
||||
return normalizeReleaseNotesForDisplay(selectedRelease.value?.release_notes)
|
||||
})
|
||||
const selectedReleaseNotesHtml = computed(() => {
|
||||
if (!selectedReleaseDisplayNotes.value) return ''
|
||||
try {
|
||||
const html = marked.parse(selectedReleaseDisplayNotes.value, {
|
||||
async: false,
|
||||
breaks: true
|
||||
}) as string
|
||||
return sanitizeMarkdown(html)
|
||||
} catch {
|
||||
return selectedReleaseDisplayNotes.value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReleases(force = false) {
|
||||
if (releasesFetched && !force) return
|
||||
loadingReleases.value = true
|
||||
releasesError.value = null
|
||||
try {
|
||||
const data = await adminApi.getSystemReleases(force)
|
||||
releases.value = data.releases
|
||||
releasesError.value = data.error
|
||||
releasesFetched = true
|
||||
} catch (err) {
|
||||
releasesError.value = err instanceof Error ? err.message : '获取版本列表失败'
|
||||
} finally {
|
||||
loadingReleases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReleases() {
|
||||
showReleases.value = !showReleases.value
|
||||
if (showReleases.value) {
|
||||
fetchReleases()
|
||||
}
|
||||
}
|
||||
|
||||
function openReleaseDetails(release: ReleaseEntry) {
|
||||
selectedRelease.value = release
|
||||
isOpen.value = false
|
||||
showReleaseDetails.value = true
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
releasesFetched = false
|
||||
if (showReleases.value) {
|
||||
fetchReleases(true)
|
||||
}
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
@@ -169,4 +569,25 @@ function handleOpenRelease() {
|
||||
isOpen.value = false
|
||||
emit('openRelease')
|
||||
}
|
||||
|
||||
function handleOpenSelectedReleasePage() {
|
||||
if (selectedRelease.value?.release_url) {
|
||||
window.open(selectedRelease.value.release_url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
function handleUseSelectedRelease() {
|
||||
if (!selectedRelease.value || !canUseSelectedRelease.value) return
|
||||
showReleaseDetails.value = false
|
||||
isOpen.value = false
|
||||
emit('previewRelease', selectedRelease.value)
|
||||
}
|
||||
|
||||
function handleApplyUpdate() {
|
||||
emit('applyUpdate')
|
||||
}
|
||||
|
||||
function handleRollback() {
|
||||
emit('rollback')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -118,8 +118,18 @@
|
||||
v-if="isAdmin"
|
||||
:status="versionStatus"
|
||||
:loading="loadingVersionStatus"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
:update-supported="updateSupported"
|
||||
:rollback-available="rollbackAvailable"
|
||||
:rolling-back="rollingBack"
|
||||
:download-progress-text="updateProgressText"
|
||||
:download-progress-percent="updateProgressPercent"
|
||||
@refresh="handleVersionRefresh"
|
||||
@open-release="openVersionReleasePage"
|
||||
@preview-release="openReleaseUpdateDialog"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
<button
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||
@@ -301,8 +311,18 @@
|
||||
v-if="isAdmin"
|
||||
:status="versionStatus"
|
||||
:loading="loadingVersionStatus"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
:update-supported="updateSupported"
|
||||
:rollback-available="rollbackAvailable"
|
||||
:rolling-back="rollingBack"
|
||||
:download-progress-text="updateProgressText"
|
||||
:download-progress-percent="updateProgressPercent"
|
||||
@refresh="handleVersionRefresh"
|
||||
@open-release="openVersionReleasePage"
|
||||
@preview-release="openReleaseUpdateDialog"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
<!-- Theme Toggle -->
|
||||
<button
|
||||
@@ -385,6 +405,21 @@
|
||||
:release-url="updateInfo.release_url"
|
||||
:release-notes="updateInfo.release_notes"
|
||||
:published-at="updateInfo.published_at"
|
||||
:dialog-title="updateDialogTitle"
|
||||
:version-label="updateDialogVersionLabel"
|
||||
:release-link-label="updateDialogReleaseLinkLabel"
|
||||
:updating="applyingSystemUpdate"
|
||||
:update-phase="systemUpdatePhase"
|
||||
:update-supported="updateSupported"
|
||||
:updatable="updateInfo.updatable"
|
||||
:update-blocker="updateInfo.update_blocker"
|
||||
:reconnect-message="reconnectMessage"
|
||||
:rollback-available="rollbackAvailable"
|
||||
:rolling-back="rollingBack"
|
||||
:download-progress-text="updateProgressText"
|
||||
:download-progress-percent="updateProgressPercent"
|
||||
@apply-update="handleApplySystemUpdate"
|
||||
@rollback="handleRollback"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -397,9 +432,11 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useModuleStore } from '@/stores/modules'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { useSiteInfo } from '@/composables/useSiteInfo'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { adminApi, type CheckUpdateResponse } from '@/api/admin'
|
||||
import { adminApi, type CheckUpdateResponse, type ReleaseEntry, type UpdateTaskStatusResponse } from '@/api/admin'
|
||||
import { announcementApi, type Announcement } from '@/api/announcements'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import AppShell from '@/components/layout/AppShell.vue'
|
||||
@@ -450,12 +487,15 @@ import { BUILTIN_TOOL_BREADCRUMBS } from '@/config/builtin-tools'
|
||||
import { prefetchAdminNavigationTarget } from '@/utils/adminNavigationPrefetch'
|
||||
import { sanitizeMarkdown } from '@/utils/sanitize'
|
||||
|
||||
type SystemUpdatePhase = 'download' | 'restart' | 'reconnecting'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const moduleStore = useModuleStore()
|
||||
const { themeMode, toggleDarkMode } = useDarkMode()
|
||||
const { siteName, siteSubtitle } = useSiteInfo()
|
||||
const { success, error: showError } = useToast()
|
||||
const isDemo = computed(() => isDemoMode())
|
||||
const isAdmin = computed(() => authStore.user?.role === 'admin')
|
||||
|
||||
@@ -476,7 +516,154 @@ const showUpdateDialog = ref(false)
|
||||
const updateInfo = ref<CheckUpdateResponse | null>(null)
|
||||
const versionStatus = ref<CheckUpdateResponse | null>(null)
|
||||
const loadingVersionStatus = ref(false)
|
||||
const applyingSystemUpdate = ref(false)
|
||||
const updateSupported = ref(true)
|
||||
const reconnectMessage = ref('等待服务恢复...')
|
||||
const rollbackAvailable = ref(false)
|
||||
const rollingBack = ref(false)
|
||||
const updateTaskStatus = ref<UpdateTaskStatusResponse | null>(null)
|
||||
const updateDialogMode = ref<'latest' | 'selected'>('latest')
|
||||
const systemUpdatePhase = ref<SystemUpdatePhase>(readStoredSystemUpdatePhase())
|
||||
const preparedUpdateVersion = ref<string | null>(
|
||||
readSessionStorageItem('aether_prepared_update_version')
|
||||
)
|
||||
const SOURCE_BUILD_UPDATE_HINT = '当前为源码构建,请使用 git pull 后重新编译。'
|
||||
const SOURCE_BUILD_RELEASE_HINT = '当前为源码构建,请手动切换到对应标签后重新编译。'
|
||||
let versionStatusLoadPromise: Promise<CheckUpdateResponse | null> | null = null
|
||||
let updateStatusPollTimer: number | null = null
|
||||
const updateProgressPercent = computed(() => updateTaskStatus.value?.progress_percent ?? null)
|
||||
const updateProgressText = computed(() => formatUpdateProgressText(updateTaskStatus.value))
|
||||
const updateDialogTitle = computed(() => {
|
||||
if (updateDialogMode.value === 'selected') {
|
||||
return updateSupported.value ? '切换版本' : '版本详情'
|
||||
}
|
||||
return '发现新版本'
|
||||
})
|
||||
const updateDialogVersionLabel = computed(() => {
|
||||
if (updateDialogMode.value === 'selected') {
|
||||
return updateSupported.value ? '目标版本' : '版本标签'
|
||||
}
|
||||
return '最新版本'
|
||||
})
|
||||
const updateDialogReleaseLinkLabel = computed(() => {
|
||||
if (updateDialogMode.value === 'selected') return '查看标签页'
|
||||
return updateSupported.value ? '查看更新' : '查看发布'
|
||||
})
|
||||
|
||||
watch(systemUpdatePhase, (val) => {
|
||||
setSessionStorageItem('aether_update_phase', val)
|
||||
})
|
||||
watch(preparedUpdateVersion, (val) => {
|
||||
if (val) {
|
||||
setSessionStorageItem('aether_prepared_update_version', val)
|
||||
} else {
|
||||
removeSessionStorageItem('aether_prepared_update_version')
|
||||
}
|
||||
})
|
||||
|
||||
function readStoredSystemUpdatePhase(): SystemUpdatePhase {
|
||||
const stored = readSessionStorageItem('aether_update_phase')
|
||||
if (stored === 'restart' || stored === 'reconnecting') return stored
|
||||
return 'download'
|
||||
}
|
||||
|
||||
function readSessionStorageItem(key: string): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionStorageItem(key: string, value: string) {
|
||||
try {
|
||||
sessionStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Ignore storage failures; update state still lives in memory for this page.
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionStorageItem(key: string) {
|
||||
try {
|
||||
sessionStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore storage failures; update state still lives in memory for this page.
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdateProgressText(status: UpdateTaskStatusResponse | null): string {
|
||||
if (!status) return '正在下载更新包...'
|
||||
const label = status.progress_label ? `正在下载${status.progress_label}` : formatUpdateTaskPhase(status.phase)
|
||||
const downloaded = status.downloaded_bytes
|
||||
const total = status.total_bytes
|
||||
if (typeof downloaded === 'number' && typeof total === 'number' && total > 0) {
|
||||
return `${label} ${formatFileSize(downloaded)} / ${formatFileSize(total)}`
|
||||
}
|
||||
if (typeof downloaded === 'number' && downloaded > 0) {
|
||||
return `${label} ${formatFileSize(downloaded)}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
function formatUpdateTaskPhase(phase: string): string {
|
||||
switch (phase) {
|
||||
case 'downloading':
|
||||
return '正在下载更新包'
|
||||
case 'downloading_checksum':
|
||||
return '正在下载校验文件'
|
||||
case 'verifying':
|
||||
return '正在校验更新包'
|
||||
case 'extracting':
|
||||
return '正在解压更新包'
|
||||
case 'prepared':
|
||||
return '更新包已准备完成'
|
||||
default:
|
||||
return '正在准备更新'
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
async function refreshUpdateTaskStatus() {
|
||||
try {
|
||||
updateTaskStatus.value = await adminApi.getUpdateStatus()
|
||||
} catch {
|
||||
// Keep the last progress snapshot while the request is in flight or the service restarts.
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPreparedUpdate(): Promise<UpdateTaskStatusResponse> {
|
||||
const deadline = Date.now() + 10 * 60 * 1000
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await refreshUpdateTaskStatus()
|
||||
const status = updateTaskStatus.value
|
||||
if (status?.phase === 'prepared') return status
|
||||
if (status?.phase === 'failed') {
|
||||
throw new Error(status.error || '下载更新失败')
|
||||
}
|
||||
}
|
||||
throw new Error('下载更新超时')
|
||||
}
|
||||
|
||||
function startUpdateStatusPolling() {
|
||||
stopUpdateStatusPolling()
|
||||
void refreshUpdateTaskStatus()
|
||||
updateStatusPollTimer = window.setInterval(() => {
|
||||
void refreshUpdateTaskStatus()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopUpdateStatusPolling() {
|
||||
if (updateStatusPollTimer !== null) {
|
||||
window.clearInterval(updateStatusPollTimer)
|
||||
updateStatusPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化时自动关闭移动端菜单
|
||||
watch(() => route.path, () => {
|
||||
@@ -501,14 +688,29 @@ function shouldShowUpdatePrompt(latestVersion: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadVersionStatus() {
|
||||
async function loadVersionStatus(force = false) {
|
||||
if (!isAdmin.value) return null
|
||||
if (versionStatusLoadPromise) return versionStatusLoadPromise
|
||||
|
||||
loadingVersionStatus.value = true
|
||||
versionStatusLoadPromise = (async () => {
|
||||
try {
|
||||
versionStatus.value = await adminApi.checkUpdate()
|
||||
const [status, capability] = await Promise.all([
|
||||
adminApi.checkUpdate(force),
|
||||
adminApi.getSystemUpdateCapability().catch(() => null),
|
||||
])
|
||||
if (capability) {
|
||||
rollbackAvailable.value = capability.rollback_available
|
||||
updateSupported.value = capability.supported
|
||||
}
|
||||
versionStatus.value = capability?.supported === false && status.has_update
|
||||
? {
|
||||
...status,
|
||||
updatable: false,
|
||||
update_blocker: SOURCE_BUILD_UPDATE_HINT,
|
||||
}
|
||||
: status
|
||||
syncSystemUpdatePhase(versionStatus.value)
|
||||
return versionStatus.value
|
||||
} catch (error) {
|
||||
versionStatus.value = buildUpdateErrorStatus(versionStatus.value, error)
|
||||
@@ -522,8 +724,22 @@ async function loadVersionStatus() {
|
||||
return versionStatusLoadPromise
|
||||
}
|
||||
|
||||
function syncSystemUpdatePhase(status: CheckUpdateResponse | null) {
|
||||
if (systemUpdatePhase.value === 'reconnecting') return
|
||||
if (systemUpdatePhase.value === 'restart') {
|
||||
if (!preparedUpdateVersion.value) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!status?.has_update) {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleVersionRefresh() {
|
||||
void loadVersionStatus()
|
||||
void loadVersionStatus(true)
|
||||
}
|
||||
|
||||
function openVersionReleasePage() {
|
||||
@@ -532,8 +748,172 @@ function openVersionReleasePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateInfoFromRelease(release: ReleaseEntry): CheckUpdateResponse {
|
||||
const currentVersion =
|
||||
versionStatus.value?.current_version ||
|
||||
updateInfo.value?.current_version ||
|
||||
__APP_VERSION__ ||
|
||||
''
|
||||
const sourceBuild = !updateSupported.value
|
||||
return {
|
||||
current_version: currentVersion,
|
||||
latest_version: release.version,
|
||||
has_update: !release.is_current,
|
||||
updatable: !sourceBuild && !release.is_current && release.updatable,
|
||||
update_blocker: release.is_current
|
||||
? '当前已是这个版本'
|
||||
: sourceBuild
|
||||
? SOURCE_BUILD_RELEASE_HINT
|
||||
: release.update_blocker,
|
||||
release_url: release.release_url,
|
||||
release_notes: release.release_notes,
|
||||
published_at: release.published_at,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function openReleaseUpdateDialog(release: ReleaseEntry) {
|
||||
updateDialogMode.value = 'selected'
|
||||
updateInfo.value = buildUpdateInfoFromRelease(release)
|
||||
if (systemUpdatePhase.value !== 'reconnecting') {
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
showUpdateDialog.value = true
|
||||
}
|
||||
|
||||
async function handleApplySystemUpdate() {
|
||||
if (applyingSystemUpdate.value) return
|
||||
applyingSystemUpdate.value = true
|
||||
try {
|
||||
const capability = await adminApi.getSystemUpdateCapability()
|
||||
rollbackAvailable.value = capability.rollback_available
|
||||
if (!capability.supported) {
|
||||
updateSupported.value = false
|
||||
showError(
|
||||
SOURCE_BUILD_UPDATE_HINT,
|
||||
'不支持在线更新'
|
||||
)
|
||||
return
|
||||
}
|
||||
updateSupported.value = true
|
||||
|
||||
if (systemUpdatePhase.value === 'download') {
|
||||
const targetStatus = updateInfo.value || versionStatus.value
|
||||
if (targetStatus?.has_update && targetStatus.updatable === false) {
|
||||
showError(
|
||||
targetStatus.update_blocker || '当前版本暂不支持在线更新',
|
||||
'无法在线更新'
|
||||
)
|
||||
return
|
||||
}
|
||||
const targetVersion = updateInfo.value?.latest_version || versionStatus.value?.latest_version || null
|
||||
updateTaskStatus.value = null
|
||||
startUpdateStatusPolling()
|
||||
try {
|
||||
const result = await adminApi.prepareSystemUpdate(targetVersion)
|
||||
const finalStatus = await waitForPreparedUpdate()
|
||||
preparedUpdateVersion.value = targetVersion
|
||||
systemUpdatePhase.value = 'restart'
|
||||
success(finalStatus.output || result.message || '更新包已下载完成,请点击“立即重启”完成安装')
|
||||
} finally {
|
||||
stopUpdateStatusPolling()
|
||||
void refreshUpdateTaskStatus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const result = await adminApi.applySystemUpdate(preparedUpdateVersion.value)
|
||||
success(result.message || '一键重启已启动')
|
||||
systemUpdatePhase.value = 'reconnecting'
|
||||
reconnectMessage.value = '服务正在重启...'
|
||||
showUpdateDialog.value = true
|
||||
applyingSystemUpdate.value = false
|
||||
await pollHealthUntilReady()
|
||||
} catch (err) {
|
||||
const fallback = systemUpdatePhase.value === 'download' ? '下载更新失败' : '启动重启失败'
|
||||
showError(parseApiError(err, fallback))
|
||||
} finally {
|
||||
applyingSystemUpdate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRollback() {
|
||||
if (rollingBack.value) return
|
||||
rollingBack.value = true
|
||||
try {
|
||||
const result = await adminApi.rollbackSystemUpdate()
|
||||
success(result.message || '回滚已启动')
|
||||
systemUpdatePhase.value = 'reconnecting'
|
||||
reconnectMessage.value = '正在回滚到上一版本...'
|
||||
showUpdateDialog.value = true
|
||||
rollingBack.value = false
|
||||
await pollHealthUntilReady()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, '回滚失败'))
|
||||
} finally {
|
||||
rollingBack.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollHealthUntilReady() {
|
||||
const maxAttempts = 60
|
||||
const intervalMs = 2000
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (i < 3) {
|
||||
reconnectMessage.value = i === 0 ? '服务正在重启...' : `服务正在重启... (${i * 2}s)`
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
continue
|
||||
}
|
||||
|
||||
const elapsed = i * 2
|
||||
reconnectMessage.value = `等待服务恢复... (${elapsed}s)`
|
||||
try {
|
||||
const resp = await fetch('/_gateway/health', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
if (resp.ok) {
|
||||
reconnectMessage.value = '服务已恢复,正在刷新...'
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
window.location.replace(buildFreshReloadUrl())
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// expected while service is down
|
||||
}
|
||||
|
||||
// After 30 seconds, start checking if the task actually failed
|
||||
if (i > 15) {
|
||||
try {
|
||||
const status = await adminApi.getUpdateStatus()
|
||||
if (status.phase === 'failed' && status.error) {
|
||||
reconnectMessage.value = `更新失败: ${status.error}`
|
||||
systemUpdatePhase.value = 'download'
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// service still down, continue polling
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, intervalMs))
|
||||
}
|
||||
|
||||
reconnectMessage.value = '等待超时,请手动刷新页面'
|
||||
systemUpdatePhase.value = 'download'
|
||||
}
|
||||
|
||||
function buildFreshReloadUrl(): string {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('__aether_reload', Date.now().toString())
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function showDebugUpdateDialog() {
|
||||
const currentVersion = versionStatus.value?.current_version || __APP_VERSION__ || '0.7.0-rc28'
|
||||
updateDialogMode.value = 'latest'
|
||||
updateInfo.value = {
|
||||
current_version: currentVersion,
|
||||
latest_version: 'v0.7.0-rc99',
|
||||
@@ -546,8 +926,12 @@ function showDebugUpdateDialog() {
|
||||
'- 统一版本号显示格式',
|
||||
].join('\n'),
|
||||
published_at: new Date().toISOString(),
|
||||
updatable: true,
|
||||
update_blocker: null,
|
||||
error: null,
|
||||
}
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
showUpdateDialog.value = true
|
||||
}
|
||||
|
||||
@@ -567,8 +951,12 @@ function showDebugVersionStatus(hasUpdate = true) {
|
||||
].join('\n')
|
||||
: null,
|
||||
published_at: hasUpdate ? new Date().toISOString() : null,
|
||||
updatable: hasUpdate,
|
||||
update_blocker: null,
|
||||
error: null,
|
||||
}
|
||||
systemUpdatePhase.value = 'download'
|
||||
preparedUpdateVersion.value = null
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
@@ -584,6 +972,7 @@ async function checkForUpdate() {
|
||||
const result = versionStatus.value ?? await loadVersionStatus()
|
||||
if (result?.has_update && result.latest_version) {
|
||||
if (shouldShowUpdatePrompt(result.latest_version)) {
|
||||
updateDialogMode.value = 'latest'
|
||||
updateInfo.value = result
|
||||
showUpdateDialog.value = true
|
||||
}
|
||||
@@ -676,6 +1065,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopUpdateStatusPolling()
|
||||
if (import.meta.env.DEV && window.__aetherShowUpdateDialog === showDebugUpdateDialog) {
|
||||
delete window.__aetherShowUpdateDialog
|
||||
}
|
||||
|
||||
85
frontend/src/utils/__tests__/releaseNotes.spec.ts
Normal file
85
frontend/src/utils/__tests__/releaseNotes.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeReleaseNotesForDisplay, trimReleaseNotesForDisplay } from '../releaseNotes'
|
||||
|
||||
describe('trimReleaseNotesForDisplay', () => {
|
||||
it('keeps manual notes and removes GitHub auto-generated sections', () => {
|
||||
const input = [
|
||||
'### Features',
|
||||
'- 新增在线更新',
|
||||
'',
|
||||
'### Bug Fixes',
|
||||
'- 修复版本检查',
|
||||
'',
|
||||
'## What\'s Changed',
|
||||
'* fix: something by @someone in https://github.com/example/repo/pull/1',
|
||||
'',
|
||||
'## New Contributors',
|
||||
'* @someone made their first contribution',
|
||||
'',
|
||||
'**Full Changelog**: https://github.com/example/repo/compare/v1...v2',
|
||||
].join('\n')
|
||||
|
||||
expect(trimReleaseNotesForDisplay(input)).toBe([
|
||||
'### Features',
|
||||
'- 新增在线更新',
|
||||
'',
|
||||
'### Bug Fixes',
|
||||
'- 修复版本检查',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('removes standalone full changelog lines', () => {
|
||||
const input = '**Full Changelog**: https://github.com/example/repo/compare/v1...v2'
|
||||
expect(trimReleaseNotesForDisplay(input)).toBe('')
|
||||
})
|
||||
|
||||
it('returns original notes when there is no auto-generated footer', () => {
|
||||
const input = [
|
||||
'### Features',
|
||||
'- 支持历史版本切换',
|
||||
].join('\n')
|
||||
|
||||
expect(trimReleaseNotesForDisplay(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeReleaseNotesForDisplay', () => {
|
||||
it('keeps existing markdown structure intact', () => {
|
||||
const input = [
|
||||
'### Features',
|
||||
'- 新增在线更新',
|
||||
'',
|
||||
'### Fixes',
|
||||
'- 修复版本检查',
|
||||
].join('\n')
|
||||
|
||||
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
|
||||
})
|
||||
|
||||
it('converts plain multi-section notes into markdown sections', () => {
|
||||
const input = [
|
||||
'Features',
|
||||
'新增在线更新弹窗',
|
||||
'支持选择历史版本',
|
||||
'',
|
||||
'问题修复',
|
||||
'修复下载进度显示不准',
|
||||
'修复版本详情跳转错误',
|
||||
].join('\n')
|
||||
|
||||
expect(normalizeReleaseNotesForDisplay(input)).toBe([
|
||||
'### Features',
|
||||
'- 新增在线更新弹窗',
|
||||
'- 支持选择历史版本',
|
||||
'',
|
||||
'### 问题修复',
|
||||
'- 修复下载进度显示不准',
|
||||
'- 修复版本详情跳转错误',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('does not over-normalize plain paragraph text', () => {
|
||||
const input = '这次更新主要修复了在线更新流程中的代理问题,并优化了历史版本切换体验。'
|
||||
expect(normalizeReleaseNotesForDisplay(input)).toBe(input)
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,8 @@ function updateStatus(overrides: Partial<CheckUpdateResponse> = {}): CheckUpdate
|
||||
current_version: '0.7.0-rc27',
|
||||
latest_version: null,
|
||||
has_update: false,
|
||||
updatable: false,
|
||||
update_blocker: null,
|
||||
release_url: null,
|
||||
release_notes: null,
|
||||
published_at: null,
|
||||
|
||||
144
frontend/src/utils/releaseNotes.ts
Normal file
144
frontend/src/utils/releaseNotes.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
const AUTO_GENERATED_RELEASE_SECTION_PATTERNS = [
|
||||
/^#{1,6}\s+what'?s changed\s*$/i,
|
||||
/^#{1,6}\s+new contributors\s*$/i,
|
||||
/^\*\*full changelog\*\*:/i,
|
||||
]
|
||||
|
||||
const MARKDOWN_STRUCTURE_PATTERNS = [
|
||||
/^#{1,6}\s+\S/,
|
||||
/^\s*[-*+]\s+\S/,
|
||||
/^\s*\d+\.\s+\S/,
|
||||
/^\s*>\s+\S/,
|
||||
/^\s*```/,
|
||||
/^\s*---+\s*$/,
|
||||
/^\s*\|(?:[^|]+\|)+\s*$/,
|
||||
/`[^`]+`/,
|
||||
/\[[^\]]+\]\([^)]+\)/,
|
||||
]
|
||||
|
||||
const SENTENCE_END_PUNCTUATION = /[,。,.!?!?;;]$/
|
||||
const SECTION_HEADING_SUFFIX = /[::]\s*$/
|
||||
const URL_PATTERN = /https?:\/\/|www\./i
|
||||
const BRACKET_PREFIX_PATTERN = /^[\[((【<]/
|
||||
const SECTION_HEADING_TEXT_PATTERN = /[\u3400-\u9FFFA-Za-z]/
|
||||
|
||||
function isStructuredMarkdownLine(line: string): boolean {
|
||||
return MARKDOWN_STRUCTURE_PATTERNS.some((pattern) => pattern.test(line))
|
||||
}
|
||||
|
||||
function normalizeSectionHeading(line: string): string {
|
||||
return line.replace(SECTION_HEADING_SUFFIX, '').trim()
|
||||
}
|
||||
|
||||
function looksLikeSectionHeading(line: string): boolean {
|
||||
const trimmed = normalizeSectionHeading(line)
|
||||
|
||||
if (!trimmed) return false
|
||||
if (trimmed.length > 24) return false
|
||||
if (URL_PATTERN.test(trimmed)) return false
|
||||
if (BRACKET_PREFIX_PATTERN.test(trimmed)) return false
|
||||
if (isStructuredMarkdownLine(trimmed)) return false
|
||||
if (SENTENCE_END_PUNCTUATION.test(trimmed)) return false
|
||||
if (!SECTION_HEADING_TEXT_PATTERN.test(trimmed)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectConfirmedHeadingIndexes(lines: string[]): Set<number> {
|
||||
const candidates = lines
|
||||
.map((line, index) => {
|
||||
if (!looksLikeSectionHeading(line.trim())) return -1
|
||||
if (index === 0) return index
|
||||
return lines[index - 1].trim() === '' ? index : -1
|
||||
})
|
||||
.filter((index) => index !== -1)
|
||||
|
||||
const confirmed = new Set<number>()
|
||||
|
||||
for (let i = 0; i < candidates.length; i += 1) {
|
||||
const current = candidates[i]
|
||||
const next = i + 1 < candidates.length ? candidates[i + 1] : lines.length
|
||||
|
||||
for (let cursor = current + 1; cursor < next; cursor += 1) {
|
||||
const content = lines[cursor].trim()
|
||||
if (!content) continue
|
||||
confirmed.add(current)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return confirmed
|
||||
}
|
||||
|
||||
function collapseBlankLines(lines: string[]): string {
|
||||
return lines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function trimReleaseNotesForDisplay(notes: string | null | undefined): string {
|
||||
if (!notes) return ''
|
||||
|
||||
const normalized = notes.replace(/\r\n?/g, '\n').trim()
|
||||
if (!normalized) return ''
|
||||
|
||||
const lines = normalized.split('\n')
|
||||
const cutoff = lines.findIndex((line) => {
|
||||
const trimmed = line.trim()
|
||||
return AUTO_GENERATED_RELEASE_SECTION_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||
})
|
||||
|
||||
if (cutoff === -1) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return lines.slice(0, cutoff).join('\n').trim()
|
||||
}
|
||||
|
||||
export function normalizeReleaseNotesForDisplay(notes: string | null | undefined): string {
|
||||
const trimmed = trimReleaseNotesForDisplay(notes)
|
||||
if (!trimmed) return ''
|
||||
|
||||
const lines = trimmed.split('\n')
|
||||
if (lines.some((line) => isStructuredMarkdownLine(line.trim()))) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const headingIndexes = collectConfirmedHeadingIndexes(lines)
|
||||
if (headingIndexes.size < 2) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const normalized: string[] = []
|
||||
let insideSection = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmedLine = lines[index].trim()
|
||||
|
||||
if (!trimmedLine) {
|
||||
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
|
||||
normalized.push('')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (headingIndexes.has(index)) {
|
||||
if (normalized.length > 0 && normalized[normalized.length - 1] !== '') {
|
||||
normalized.push('')
|
||||
}
|
||||
normalized.push(`### ${normalizeSectionHeading(trimmedLine)}`)
|
||||
insideSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (insideSection) {
|
||||
normalized.push(`- ${trimmedLine.replace(/^[-*+•]\s*/, '')}`)
|
||||
continue
|
||||
}
|
||||
|
||||
normalized.push(trimmedLine)
|
||||
}
|
||||
|
||||
return collapseBlankLines(normalized)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export function buildUpdateErrorStatus(
|
||||
current_version: previousStatus?.current_version || '',
|
||||
latest_version: null,
|
||||
has_update: false,
|
||||
updatable: false,
|
||||
update_blocker: null,
|
||||
release_url: null,
|
||||
release_notes: null,
|
||||
published_at: null,
|
||||
|
||||
Reference in New Issue
Block a user