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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user