feat: add changelog functionality and dialog component (#4135)

* feat: add changelog functionality and dialog component

- Implemented new routes for fetching changelogs and available versions in StatRoute.
- Created ChangelogDialog.vue for displaying changelog content and version selection.
- Updated VerticalSidebar.vue to include a button for opening the changelog dialog.
- Enhanced localization files for English and Chinese to support new changelog features.
- Adjusted styles in VerticalHeader.vue for improved layout consistency.

* chore: ruff format
This commit is contained in:
Soulter
2025-12-20 16:33:12 +08:00
committed by GitHub
parent 3ee6af7027
commit 82330b8d10
6 changed files with 340 additions and 2 deletions
+96
View File
@@ -1,6 +1,9 @@
import os
import re
import threading
import time
import traceback
from functools import cmp_to_key
import aiohttp
import psutil
@@ -11,7 +14,9 @@ from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext
@@ -30,6 +35,8 @@ class StatRoute(Route):
"/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core),
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
}
self.db_helper = db_helper
self.register_routes()
@@ -183,3 +190,92 @@ class StatRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def get_changelog(self):
"""获取指定版本的更新日志"""
try:
version = request.args.get("version")
if not version:
return Response().error("version parameter is required").__dict__
version = version.lstrip("v")
# 防止路径遍历攻击
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
return Response().error("Invalid version format").__dict__
if ".." in version or "/" in version or "\\" in version:
return Response().error("Invalid version format").__dict__
filename = f"v{version}.md"
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
changelog_path = os.path.join(changelogs_dir, filename)
# 规范化路径,防止符号链接攻击
changelog_path = os.path.realpath(changelog_path)
changelogs_dir = os.path.realpath(changelogs_dir)
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
changelog_path_normalized = os.path.normpath(changelog_path)
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
expected_prefix = changelogs_dir_normalized + os.sep
if not changelog_path_normalized.startswith(expected_prefix):
logger.warning(
f"Path traversal attempt detected: {version} -> {changelog_path}",
)
return Response().error("Invalid version format").__dict__
if not os.path.exists(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
if not os.path.isfile(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
with open(changelog_path, encoding="utf-8") as f:
content = f.read()
return Response().ok({"content": content, "version": version}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def list_changelog_versions(self):
"""获取所有可用的更新日志版本列表"""
try:
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
if not os.path.exists(changelogs_dir):
return Response().ok({"versions": []}).__dict__
versions = []
for filename in os.listdir(changelogs_dir):
if filename.endswith(".md") and filename.startswith("v"):
# 提取版本号(去除 v 前缀和 .md 后缀)
version = filename[1:-3] # 去掉 "v" 和 ".md"
# 验证版本号格式
if re.match(r"^[a-zA-Z0-9._-]+$", version):
versions.append(version)
# 按版本号排序(降序,最新的在前)
# 使用项目中的 VersionComparator 进行语义化版本号排序
versions.sort(
key=cmp_to_key(
lambda v1, v2: VersionComparator.compare_version(v2, v1),
),
)
return Response().ok({"versions": versions}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
@@ -0,0 +1,209 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from '@/i18n/composables';
import axios from 'axios';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
enableKatex();
enableMermaid();
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const changelogContent = ref('');
const changelogLoading = ref(false);
const changelogError = ref('');
const changelogVersion = ref('');
const selectedVersion = ref('');
const availableVersions = ref([]);
const loadingVersions = ref(false);
// 获取当前版本号(从版本信息中提取)
async function getCurrentVersion() {
try {
const res = await axios.get('/api/stat/version');
const version = res.data.data?.version || '';
changelogVersion.value = version;
selectedVersion.value = version;
return version;
} catch (err) {
console.error('Failed to get version:', err);
return '';
}
}
// 加载更新日志
async function loadChangelog(version) {
const targetVersion = version || selectedVersion.value || changelogVersion.value;
if (!targetVersion) {
changelogError.value = t('core.navigation.changelogDialog.selectVersion');
return;
}
changelogLoading.value = true;
changelogError.value = '';
changelogContent.value = '';
try {
const res = await axios.get('/api/stat/changelog', {
params: { version: targetVersion }
});
if (res.data.status === 'ok') {
changelogContent.value = res.data.data.content;
selectedVersion.value = targetVersion;
} else {
changelogError.value = res.data.message || t('core.navigation.changelogDialog.error');
}
} catch (err) {
console.error('Failed to load changelog:', err);
if (err.response?.status === 404 || err.response?.data?.message?.includes('not found')) {
changelogError.value = t('core.navigation.changelogDialog.notFound');
} else {
changelogError.value = t('core.navigation.changelogDialog.error');
}
} finally {
changelogLoading.value = false;
}
}
// 获取所有可用版本列表
async function loadAvailableVersions() {
loadingVersions.value = true;
try {
const res = await axios.get('/api/stat/changelog/list');
if (res.data.status === 'ok') {
availableVersions.value = res.data.data.versions || [];
}
} catch (err) {
console.error('Failed to load versions:', err);
} finally {
loadingVersions.value = false;
}
}
// 版本选择变化时加载对应的更新日志
function onVersionChange() {
if (selectedVersion.value) {
loadChangelog(selectedVersion.value);
}
}
// 监听对话框打开,初始化数据
watch(dialog, async (newValue) => {
if (newValue) {
// 加载版本列表
await loadAvailableVersions();
// 获取当前版本
if (!changelogVersion.value) {
await getCurrentVersion();
}
// 如果当前版本在列表中,默认选择当前版本
if (changelogVersion.value && availableVersions.value.includes(changelogVersion.value)) {
selectedVersion.value = changelogVersion.value;
await loadChangelog();
} else if (availableVersions.value.length > 0) {
// 否则选择第一个(最新的)
selectedVersion.value = availableVersions.value[0];
await loadChangelog(availableVersions.value[0]);
}
} else {
// 关闭时重置状态
changelogContent.value = '';
changelogError.value = '';
}
});
// 初始化时获取版本号
getCurrentVersion();
</script>
<template>
<v-dialog
:model-value="dialog"
@update:model-value="dialog = $event"
:width="$vuetify.display.smAndDown ? '100%' : '800'"
:fullscreen="$vuetify.display.xs"
max-width="1000"
>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h3">{{ t('core.navigation.changelogDialog.title') }}</span>
<v-btn icon @click="dialog = false" flat>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<!-- 版本选择器 -->
<div class="mb-4">
<v-select
v-model="selectedVersion"
:items="availableVersions"
:label="t('core.navigation.changelogDialog.selectVersion')"
:loading="loadingVersions"
variant="outlined"
density="compact"
@update:model-value="onVersionChange"
>
<template v-slot:item="{ item, props }">
<v-list-item v-bind="props" :title="`v${item.value}`">
<template v-slot:append v-if="item.value === changelogVersion">
<v-chip size="x-small" color="primary" variant="tonal">
{{ t('core.navigation.changelogDialog.current') }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-slot:selection="{ item }">
<span>v{{ item.value }}</span>
</template>
</v-select>
</div>
<!-- 更新日志内容 -->
<div style="max-height: 70vh; overflow-y: auto;">
<div v-if="changelogLoading" class="text-center py-8">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<div class="mt-4">{{ t('core.navigation.changelogDialog.loading') }}</div>
</div>
<v-alert v-else-if="changelogError" type="error" variant="tonal" border="start">
{{ changelogError }}
</v-alert>
<div v-else-if="changelogContent" class="changelog-content">
<MarkdownRender :content="changelogContent" :typewriter="false" class="markdown-content" />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="dialog = false">
{{ t('core.common.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style>
.changelog-content {
padding: 8px 0;
}
</style>
@@ -15,10 +15,19 @@
"knowledgeBase": "Knowledge Base",
"about": "About",
"settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation",
"github": "GitHub",
"drag": "Drag",
"groups": {
"more": "More Features"
},
"changelogDialog": {
"title": "Changelog",
"loading": "Loading...",
"error": "Failed to load",
"notFound": "Changelog for this version not found",
"selectVersion": "Select Version",
"current": "Current"
}
}
@@ -15,10 +15,19 @@
"knowledgeBase": "知识库",
"about": "关于",
"settings": "设置",
"changelog": "更新日志",
"documentation": "官方文档",
"github": "GitHub",
"drag": "拖拽",
"groups": {
"more": "更多功能"
},
"changelogDialog": {
"title": "更新日志",
"loading": "加载中...",
"error": "加载失败",
"notFound": "未找到该版本的更新日志",
"selectVersion": "选择版本",
"current": "当前"
}
}
@@ -696,7 +696,7 @@ const changeLanguage = async (langCode: string) => {
/* 响应式布局样式 */
.logo-container {
margin-left: 16px;
margin-left: 10px;
display: flex;
align-items: center;
gap: 8px;
@@ -5,6 +5,7 @@ import { useI18n } from '@/i18n/composables';
import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
const { t } = useI18n();
@@ -37,6 +38,9 @@ onUnmounted(() => {
const showIframe = ref(false);
const starCount = ref(null);
// 更新日志对话框
const changelogDialog = ref(false);
const sidebarWidth = ref(235);
const minSidebarWidth = 200;
const maxSidebarWidth = 300;
@@ -220,6 +224,11 @@ async function fetchStarCount() {
fetchStarCount();
// 打开更新日志对话框
function openChangelogDialog() {
changelogDialog.value = true;
}
</script>
<template>
@@ -243,6 +252,9 @@ fetchStarCount();
<v-btn style="margin-bottom: 8px;" size="small" variant="tonal" color="primary" to="/settings">
🔧 {{ t('core.navigation.settings') }}
</v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="openChangelogDialog">
📝 {{ t('core.navigation.changelog') }}
</v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="toggleIframe">
{{ t('core.navigation.documentation') }}
</v-btn>
@@ -301,8 +313,11 @@ fetchStarCount();
<iframe
src="https://astrbot.app"
style="width: 100%; height: calc(100% - 56px); border: none; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px;"
></iframe>
></iframe>
</div>
<!-- 更新日志对话框 -->
<ChangelogDialog v-model="changelogDialog" />
</template>
<style scoped>