mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
feat: enhance update dialog with progress tracking and localization updates
- Added advanced settings option in update dialog for better user control. - Implemented detailed progress tracking for update stages including download size and speed. - Updated localization files for English, Russian, and Chinese to include new strings for update progress and advanced settings. - Improved UI for update dialog with better layout and responsiveness. - Enhanced test coverage for update process including progress tracking.
This commit is contained in:
+13
-2
@@ -143,7 +143,14 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
async def get_releases(self) -> list:
|
||||
return await self.fetch_release_info(self.ASTRBOT_RELEASE_API)
|
||||
|
||||
async def update(self, reboot=False, latest=True, version=None, proxy="") -> None:
|
||||
async def update(
|
||||
self,
|
||||
reboot=False,
|
||||
latest=True,
|
||||
version=None,
|
||||
proxy="",
|
||||
progress_callback=None,
|
||||
) -> None:
|
||||
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
|
||||
file_url = None
|
||||
|
||||
@@ -175,7 +182,11 @@ class AstrBotUpdator(RepoZipUpdator):
|
||||
file_url = f"{proxy}/{file_url}"
|
||||
|
||||
try:
|
||||
await self._download_file(file_url, "temp.zip")
|
||||
await self._download_file(
|
||||
file_url,
|
||||
"temp.zip",
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
logger.info("下载 AstrBot Core 更新文件完成,正在执行解压...")
|
||||
self.unzip_file("temp.zip", self.MAIN_PATH)
|
||||
except BaseException as e:
|
||||
|
||||
+100
-12
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
@@ -147,7 +148,20 @@ async def download_image_by_url(
|
||||
raise e
|
||||
|
||||
|
||||
async def download_file(url: str, path: str, show_progress: bool = False) -> None:
|
||||
async def _emit_download_progress(progress_callback, payload: dict) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
result = progress_callback(payload)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
|
||||
async def download_file(
|
||||
url: str,
|
||||
path: str,
|
||||
show_progress: bool = False,
|
||||
progress_callback=None,
|
||||
) -> None:
|
||||
"""从指定 url 下载文件到指定路径 path"""
|
||||
try:
|
||||
ssl_context = ssl.create_default_context(
|
||||
@@ -168,6 +182,16 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
|
||||
start_time = time.time()
|
||||
if show_progress:
|
||||
print(f"Downloading: {url} | Size: {total_size / 1024:.2f} KB")
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": 0,
|
||||
"total": total_size,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
with open(path, "wb") as f:
|
||||
while True:
|
||||
chunk = await resp.content.read(8192)
|
||||
@@ -175,17 +199,38 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
elapsed_time = (
|
||||
time.time() - start_time
|
||||
if time.time() - start_time > 0
|
||||
else 1
|
||||
)
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
percent = downloaded_size / total_size if total_size > 0 else 0
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": percent,
|
||||
"speed": speed,
|
||||
},
|
||||
)
|
||||
if show_progress:
|
||||
elapsed_time = (
|
||||
time.time() - start_time
|
||||
if time.time() - start_time > 0
|
||||
else 1
|
||||
)
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
print(
|
||||
f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
|
||||
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
|
||||
end="",
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": 1,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
except (aiohttp.ClientConnectorSSLError, aiohttp.ClientConnectorCertificateError):
|
||||
# 关闭SSL验证(仅在证书验证失败时作为fallback)
|
||||
logger.warning(
|
||||
@@ -208,6 +253,16 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
|
||||
start_time = time.time()
|
||||
if show_progress:
|
||||
print(f"Size: {total_size / 1024:.2f} KB | URL: {url}")
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": 0,
|
||||
"total": total_size,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
with open(path, "wb") as f:
|
||||
while True:
|
||||
chunk = await resp.content.read(8192)
|
||||
@@ -215,13 +270,38 @@ async def download_file(url: str, path: str, show_progress: bool = False) -> Non
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
elapsed_time = (
|
||||
time.time() - start_time
|
||||
if time.time() - start_time > 0
|
||||
else 1
|
||||
)
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
percent = downloaded_size / total_size if total_size > 0 else 0
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": percent,
|
||||
"speed": speed,
|
||||
},
|
||||
)
|
||||
if show_progress:
|
||||
elapsed_time = time.time() - start_time
|
||||
speed = downloaded_size / 1024 / elapsed_time # KB/s
|
||||
print(
|
||||
f"\rProgress: {downloaded_size / total_size:.2%} Speed: {speed:.2f} KB/s",
|
||||
f"\rProgress: {percent:.2%} Speed: {speed:.2f} KB/s",
|
||||
end="",
|
||||
)
|
||||
await _emit_download_progress(
|
||||
progress_callback,
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": 1,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
if show_progress:
|
||||
print()
|
||||
|
||||
@@ -268,6 +348,7 @@ async def download_dashboard(
|
||||
latest: bool = True,
|
||||
version: str | None = None,
|
||||
proxy: str | None = None,
|
||||
progress_callback=None,
|
||||
) -> None:
|
||||
"""下载管理面板文件"""
|
||||
if path is None:
|
||||
@@ -286,6 +367,7 @@ async def download_dashboard(
|
||||
dashboard_release_url,
|
||||
str(zip_path),
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
except BaseException as _:
|
||||
if latest:
|
||||
@@ -312,12 +394,18 @@ async def download_dashboard(
|
||||
dashboard_release_url,
|
||||
str(zip_path),
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
else:
|
||||
url = f"https://github.com/AstrBotDevs/astrbot-release-harbour/releases/download/release-{version}/dist.zip"
|
||||
logger.info(f"Downloading AstrBot WebUI from {url}")
|
||||
if proxy:
|
||||
url = f"{proxy}/{url}"
|
||||
await download_file(url, str(zip_path), show_progress=True)
|
||||
await download_file(
|
||||
url,
|
||||
str(zip_path),
|
||||
show_progress=True,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
with zipfile.ZipFile(zip_path, "r") as z:
|
||||
z.extractall(extract_path)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
@@ -53,18 +55,64 @@ class RepoZipUpdator:
|
||||
return body[:max_len] + "...[truncated]"
|
||||
|
||||
async def _download_file(
|
||||
self, url: str, path: str, timeout: float = 1800.0
|
||||
self,
|
||||
url: str,
|
||||
path: str,
|
||||
timeout: float = 1800.0,
|
||||
progress_callback=None,
|
||||
) -> None:
|
||||
target_path = Path(path)
|
||||
ensure_dir(target_path.parent)
|
||||
|
||||
async def _emit_progress(payload: dict) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
result = progress_callback(payload)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
try:
|
||||
async with self._create_httpx_client(timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
headers = getattr(response, "headers", {})
|
||||
total_size = int(headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
start_time = time.time()
|
||||
await _emit_progress(
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": 0,
|
||||
"total": total_size,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
with target_path.open("wb") as file:
|
||||
async for chunk in response.aiter_bytes(8192):
|
||||
file.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
elapsed_time = max(time.time() - start_time, 1)
|
||||
await _emit_progress(
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": downloaded_size / total_size
|
||||
if total_size > 0
|
||||
else 0,
|
||||
"speed": downloaded_size / 1024 / elapsed_time,
|
||||
},
|
||||
)
|
||||
await _emit_progress(
|
||||
{
|
||||
"url": url,
|
||||
"downloaded": downloaded_size,
|
||||
"total": total_size,
|
||||
"percent": 1,
|
||||
"speed": 0,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"下载文件失败: {url} -> {target_path}, 错误: {e}")
|
||||
if self.rm_on_error and target_path.exists():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import traceback
|
||||
import uuid
|
||||
|
||||
from quart import request
|
||||
|
||||
@@ -24,6 +25,7 @@ class UpdateRoute(Route):
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/update/check": ("GET", self.check_update),
|
||||
"/update/progress": ("GET", self.get_update_progress),
|
||||
"/update/releases": ("GET", self.get_releases),
|
||||
"/update/do": ("POST", self.update_project),
|
||||
"/update/dashboard": ("POST", self.update_dashboard),
|
||||
@@ -32,8 +34,104 @@ class UpdateRoute(Route):
|
||||
}
|
||||
self.astrbot_updator = astrbot_updator
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.update_progress: dict[str, dict] = {}
|
||||
self.register_routes()
|
||||
|
||||
def _init_update_progress(self, progress_id: str, version: str) -> None:
|
||||
self.update_progress[progress_id] = {
|
||||
"id": progress_id,
|
||||
"status": "running",
|
||||
"stage": "preparing",
|
||||
"version": version or "latest",
|
||||
"message": "正在准备更新...",
|
||||
"overall_percent": 0,
|
||||
"stages": {
|
||||
"dashboard": self._empty_stage("pending"),
|
||||
"core": self._empty_stage("pending"),
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _empty_stage(status: str = "pending") -> dict:
|
||||
return {
|
||||
"status": status,
|
||||
"downloaded": 0,
|
||||
"total": 0,
|
||||
"percent": 0,
|
||||
"speed": 0,
|
||||
}
|
||||
|
||||
def _set_update_stage(
|
||||
self,
|
||||
progress_id: str,
|
||||
stage: str,
|
||||
status: str,
|
||||
message: str,
|
||||
overall_percent: int | None = None,
|
||||
) -> None:
|
||||
progress = self.update_progress.get(progress_id)
|
||||
if not progress:
|
||||
return
|
||||
progress["stage"] = stage
|
||||
progress["message"] = message
|
||||
progress["stages"].setdefault(stage, self._empty_stage())
|
||||
progress["stages"][stage]["status"] = status
|
||||
if overall_percent is not None:
|
||||
progress["overall_percent"] = overall_percent
|
||||
|
||||
@staticmethod
|
||||
def _normalize_percent(value) -> int:
|
||||
try:
|
||||
percent = float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
if percent <= 1:
|
||||
percent *= 100
|
||||
return max(0, min(100, int(percent)))
|
||||
|
||||
def _make_progress_callback(
|
||||
self,
|
||||
progress_id: str,
|
||||
stage: str,
|
||||
stage_start: int,
|
||||
stage_weight: int,
|
||||
):
|
||||
def _callback(payload: dict) -> None:
|
||||
progress = self.update_progress.get(progress_id)
|
||||
if not progress:
|
||||
return
|
||||
stage_percent = self._normalize_percent(payload.get("percent"))
|
||||
progress["stage"] = stage
|
||||
progress["stages"][stage] = {
|
||||
"status": "running" if stage_percent < 100 else "done",
|
||||
"downloaded": payload.get("downloaded", 0),
|
||||
"total": payload.get("total", 0),
|
||||
"percent": stage_percent,
|
||||
"speed": payload.get("speed", 0),
|
||||
}
|
||||
progress["overall_percent"] = min(
|
||||
99,
|
||||
stage_start + int(stage_percent * stage_weight / 100),
|
||||
)
|
||||
|
||||
return _callback
|
||||
|
||||
async def get_update_progress(self):
|
||||
progress_id = request.args.get("id", "")
|
||||
if not progress_id:
|
||||
return Response().error("缺少参数 id。").__dict__
|
||||
progress = self.update_progress.get(progress_id)
|
||||
if not progress:
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{"id": progress_id, "status": "idle"},
|
||||
"没有正在进行的更新。",
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
return Response().ok(progress).__dict__
|
||||
|
||||
async def do_migration(self):
|
||||
need_migration = await check_migration_needed_v4(self.core_lifecycle.db)
|
||||
if not need_migration:
|
||||
@@ -89,6 +187,7 @@ class UpdateRoute(Route):
|
||||
data = await request.json
|
||||
version = data.get("version", "")
|
||||
reboot = data.get("reboot", True)
|
||||
progress_id = data.get("progress_id") or uuid.uuid4().hex
|
||||
if version == "" or version == "latest":
|
||||
latest = True
|
||||
version = ""
|
||||
@@ -99,33 +198,112 @@ class UpdateRoute(Route):
|
||||
if proxy:
|
||||
proxy = proxy.removesuffix("/")
|
||||
|
||||
self._init_update_progress(progress_id, version)
|
||||
try:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"running",
|
||||
"正在下载 WebUI...",
|
||||
0,
|
||||
)
|
||||
await download_dashboard(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
0,
|
||||
45,
|
||||
),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dashboard",
|
||||
"done",
|
||||
"WebUI 下载完成。",
|
||||
45,
|
||||
)
|
||||
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"running",
|
||||
"正在下载 AstrBot 项目代码...",
|
||||
45,
|
||||
)
|
||||
await self.astrbot_updator.update(
|
||||
latest=latest,
|
||||
version=version,
|
||||
proxy=proxy,
|
||||
progress_callback=self._make_progress_callback(
|
||||
progress_id,
|
||||
"core",
|
||||
45,
|
||||
45,
|
||||
),
|
||||
)
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"core",
|
||||
"done",
|
||||
"项目代码下载完成。",
|
||||
90,
|
||||
)
|
||||
|
||||
try:
|
||||
await download_dashboard(latest=latest, version=version, proxy=proxy)
|
||||
except Exception as e:
|
||||
logger.error(f"下载管理面板文件失败: {e}。")
|
||||
|
||||
# pip 更新依赖
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"running",
|
||||
"正在更新依赖...",
|
||||
92,
|
||||
)
|
||||
logger.info("更新依赖中...")
|
||||
try:
|
||||
await pip_installer.install(requirements_path="requirements.txt")
|
||||
except Exception as e:
|
||||
logger.error(f"更新依赖失败: {e}")
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"dependencies",
|
||||
"done",
|
||||
"依赖更新完成。",
|
||||
96,
|
||||
)
|
||||
|
||||
if reboot:
|
||||
self._set_update_stage(
|
||||
progress_id,
|
||||
"restart",
|
||||
"running",
|
||||
"更新成功,正在准备重启...",
|
||||
98,
|
||||
)
|
||||
await self.core_lifecycle.restart()
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
"stage": "done",
|
||||
"message": "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。",
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
ret = (
|
||||
Response()
|
||||
.ok(None, "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。")
|
||||
.__dict__
|
||||
)
|
||||
return ret, 200, CLEAR_SITE_DATA_HEADERS
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "success",
|
||||
"stage": "done",
|
||||
"message": "更新成功,AstrBot 将在下次启动时应用新的代码。",
|
||||
"overall_percent": 100,
|
||||
},
|
||||
)
|
||||
ret = (
|
||||
Response()
|
||||
.ok(None, "更新成功,AstrBot 将在下次启动时应用新的代码。")
|
||||
@@ -133,6 +311,12 @@ class UpdateRoute(Route):
|
||||
)
|
||||
return ret, 200, CLEAR_SITE_DATA_HEADERS
|
||||
except Exception as e:
|
||||
self.update_progress[progress_id].update(
|
||||
{
|
||||
"status": "error",
|
||||
"message": e.__str__(),
|
||||
},
|
||||
)
|
||||
logger.error(f"/api/update_project: {traceback.format_exc()}")
|
||||
return Response().error(e.__str__()).__dict__
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"tabs": {
|
||||
"release": "😊 Release"
|
||||
},
|
||||
"advancedSettings": "Advanced settings",
|
||||
"updateToLatest": "Update to Latest Version",
|
||||
"preRelease": "Pre-release",
|
||||
"preReleaseWarning": {
|
||||
@@ -45,7 +46,19 @@
|
||||
"switch": "Switch"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "Release Notes"
|
||||
"title": "Release Notes",
|
||||
"latestLabel": "Latest version"
|
||||
},
|
||||
"progress": {
|
||||
"target": "Target version: ",
|
||||
"preparing": "Preparing update...",
|
||||
"dashboard": "Download WebUI",
|
||||
"core": "Download project code",
|
||||
"dependencies": "Updating dependencies...",
|
||||
"restart": "Preparing restart...",
|
||||
"restarting": "Restarting AstrBot",
|
||||
"completed": "Update complete",
|
||||
"failed": "Update failed"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "Leaving AstrBot",
|
||||
@@ -75,6 +88,7 @@
|
||||
"currentVersion": "Current Version",
|
||||
"hasNewVersion": "New version available!",
|
||||
"isLatest": "Already the latest version.",
|
||||
"fallback": "Use this to re-download the WebUI if the full update path fails.",
|
||||
"downloadAndUpdate": "Download and Update"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,113 +1,127 @@
|
||||
{
|
||||
"logoTitle": "Панель управления AstrBot",
|
||||
"version": {
|
||||
"hasNewVersion": "Доступна новая версия AstrBot!",
|
||||
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Обновить",
|
||||
"account": "Аккаунт",
|
||||
"theme": {
|
||||
"light": "Светлая тема",
|
||||
"dark": "Темная тема"
|
||||
}
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Обновить AstrBot",
|
||||
"currentVersion": "Текущая версия",
|
||||
"status": {
|
||||
"checking": "Проверка обновлений...",
|
||||
"switching": "Переключение версии...",
|
||||
"updating": "Обновление..."
|
||||
},
|
||||
"tabs": {
|
||||
"release": "😊 Релиз"
|
||||
},
|
||||
"updateToLatest": "Обновить до последней версии",
|
||||
"preRelease": "Предварительная версия",
|
||||
"preReleaseWarning": {
|
||||
"title": "Внимание: предварительная версия",
|
||||
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
|
||||
"issueLink": "GitHub Issues"
|
||||
},
|
||||
"tip": "💡 ПОДСКАЗКА: ",
|
||||
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
|
||||
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
|
||||
"dockerTipLink": "watchtower",
|
||||
"dockerTipContinue": "для автоматического мониторинга и обновления.",
|
||||
"table": {
|
||||
"tag": "Тег",
|
||||
"publishDate": "Дата публикации",
|
||||
"content": "Содержание",
|
||||
"sourceUrl": "Исходный код",
|
||||
"actions": "Действия",
|
||||
"view": "Просмотр",
|
||||
"switch": "Переключить"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "Журнал изменений"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "Переход по ссылке",
|
||||
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
|
||||
"latestLabel": "Последняя версия",
|
||||
"targetVersion": "Целевая версия:",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"guideTitle": "Рекомендации после перехода:",
|
||||
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
|
||||
"guideStep2": "После завершения установки перезапустите AstrBot.",
|
||||
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Обновить десктопное приложение",
|
||||
"message": "Проверка и обновление десктопной версии AstrBot.",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"latestVersion": "Последняя версия:",
|
||||
"checking": "Проверка обновлений десктопного приложения...",
|
||||
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
|
||||
"isLatest": "Установлена последняя версия",
|
||||
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
|
||||
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
|
||||
"installFailed": "Ошибка обновления. Попробуйте позже."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Обновить только панель управления",
|
||||
"currentVersion": "Текущая версия",
|
||||
"hasNewVersion": "Доступна новая версия!",
|
||||
"isLatest": "Установлена последняя версия.",
|
||||
"downloadAndUpdate": "Скачать и обновить"
|
||||
}
|
||||
},
|
||||
"accountDialog": {
|
||||
"title": "Изменить аккаунт",
|
||||
"securityWarning": "Безопасность: Пожалуйста, смените пароль для защиты аккаунта",
|
||||
"securityWarningLegacy": "Новая версия AstrBot улучшила безопасность. Пожалуйста, измените пароль.",
|
||||
"securityWarningUpgrade": "Обнаружено устаревшее хранение пароля. Измените пароль, чтобы завершить обновление безопасности.",
|
||||
"form": {
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"newUsername": "Новое имя пользователя (опционально)",
|
||||
"passwordHint": "Не менее 8 символов, включая заглавные и строчные буквы, а также цифры",
|
||||
"confirmPasswordHint": "Введите новый пароль еще раз",
|
||||
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
|
||||
"defaultCredentials": "Имя пользователя по умолчанию — astrbot. Начальный пароль генерируется при первом запуске и отображается в консоли."
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен быть не менее 8 символов",
|
||||
"passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву",
|
||||
"passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву",
|
||||
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
|
||||
"passwordMatch": "Паролы не совпадают",
|
||||
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить изменения",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"messages": {
|
||||
"updateFailed": "Ошибка обновления, попробуйте еще раз"
|
||||
}
|
||||
"logoTitle": "Панель управления AstrBot",
|
||||
"version": {
|
||||
"hasNewVersion": "Доступна новая версия AstrBot!",
|
||||
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
|
||||
},
|
||||
"buttons": {
|
||||
"update": "Обновить",
|
||||
"account": "Аккаунт",
|
||||
"theme": {
|
||||
"light": "Светлая тема",
|
||||
"dark": "Темная тема"
|
||||
}
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Обновить AstrBot",
|
||||
"currentVersion": "Текущая версия",
|
||||
"status": {
|
||||
"checking": "Проверка обновлений...",
|
||||
"switching": "Переключение версии...",
|
||||
"updating": "Обновление..."
|
||||
},
|
||||
"tabs": {
|
||||
"release": "😊 Релиз"
|
||||
},
|
||||
"advancedSettings": "Расширенные настройки",
|
||||
"updateToLatest": "Обновить до последней версии",
|
||||
"preRelease": "Предварительная версия",
|
||||
"preReleaseWarning": {
|
||||
"title": "Внимание: предварительная версия",
|
||||
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
|
||||
"issueLink": "GitHub Issues"
|
||||
},
|
||||
"tip": "💡 ПОДСКАЗКА: ",
|
||||
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
|
||||
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
|
||||
"dockerTipLink": "watchtower",
|
||||
"dockerTipContinue": "для автоматического мониторинга и обновления.",
|
||||
"table": {
|
||||
"tag": "Тег",
|
||||
"publishDate": "Дата публикации",
|
||||
"content": "Содержание",
|
||||
"sourceUrl": "Исходный код",
|
||||
"actions": "Действия",
|
||||
"view": "Просмотр",
|
||||
"switch": "Переключить"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "Журнал изменений",
|
||||
"latestLabel": "Последняя версия"
|
||||
},
|
||||
"progress": {
|
||||
"target": "Целевая версия:",
|
||||
"preparing": "Подготовка обновления...",
|
||||
"dashboard": "Загрузка WebUI",
|
||||
"core": "Загрузка кода проекта",
|
||||
"dependencies": "Обновление зависимостей...",
|
||||
"restart": "Подготовка перезапуска...",
|
||||
"restarting": "Перезапуск AstrBot",
|
||||
"completed": "Обновление завершено",
|
||||
"failed": "Ошибка обновления"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "Переход по ссылке",
|
||||
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
|
||||
"latestLabel": "Последняя версия",
|
||||
"targetVersion": "Целевая версия:",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"guideTitle": "Рекомендации после перехода:",
|
||||
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
|
||||
"guideStep2": "После завершения установки перезапустите AstrBot.",
|
||||
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
|
||||
},
|
||||
"desktopApp": {
|
||||
"title": "Обновить десктопное приложение",
|
||||
"message": "Проверка и обновление десктопной версии AstrBot.",
|
||||
"currentVersion": "Текущая версия:",
|
||||
"latestVersion": "Последняя версия:",
|
||||
"checking": "Проверка обновлений десктопного приложения...",
|
||||
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
|
||||
"isLatest": "Установлена последняя версия",
|
||||
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
|
||||
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
|
||||
"installFailed": "Ошибка обновления. Попробуйте позже."
|
||||
},
|
||||
"dashboardUpdate": {
|
||||
"title": "Обновить только панель управления",
|
||||
"currentVersion": "Текущая версия",
|
||||
"hasNewVersion": "Доступна новая версия!",
|
||||
"isLatest": "Установлена последняя версия.",
|
||||
"fallback": "Используйте это для повторной загрузки WebUI, если полное обновление завершилось ошибкой.",
|
||||
"downloadAndUpdate": "Скачать и обновить"
|
||||
}
|
||||
},
|
||||
"accountDialog": {
|
||||
"title": "Изменить аккаунт",
|
||||
"securityWarning": "Безопасность: Пожалуйста, смените пароль для защиты аккаунта",
|
||||
"securityWarningLegacy": "Новая версия AstrBot улучшила безопасность. Пожалуйста, измените пароль.",
|
||||
"securityWarningUpgrade": "Обнаружено устаревшее хранение пароля. Измените пароль, чтобы завершить обновление безопасности.",
|
||||
"form": {
|
||||
"currentPassword": "Текущий пароль",
|
||||
"newPassword": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"newUsername": "Новое имя пользователя (опционально)",
|
||||
"passwordHint": "Не менее 8 символов, включая заглавные и строчные буквы, а также цифры",
|
||||
"confirmPasswordHint": "Введите новый пароль еще раз",
|
||||
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
|
||||
"defaultCredentials": "Имя пользователя по умолчанию — astrbot. Начальный пароль генерируется при первом запуске и отображается в консоли."
|
||||
},
|
||||
"validation": {
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен быть не менее 8 символов",
|
||||
"passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву",
|
||||
"passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву",
|
||||
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
|
||||
"passwordMatch": "Паролы не совпадают",
|
||||
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Сохранить изменения",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"messages": {
|
||||
"updateFailed": "Ошибка обновления, попробуйте еще раз"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"tabs": {
|
||||
"release": "😊 正式版"
|
||||
},
|
||||
"advancedSettings": "高级设置",
|
||||
"updateToLatest": "更新到最新版本",
|
||||
"preRelease": "预发布",
|
||||
"preReleaseWarning": {
|
||||
@@ -45,7 +46,19 @@
|
||||
"switch": "切换"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"title": "更新日志"
|
||||
"title": "更新日志",
|
||||
"latestLabel": "最新版本"
|
||||
},
|
||||
"progress": {
|
||||
"target": "目标版本:",
|
||||
"preparing": "正在准备更新...",
|
||||
"dashboard": "下载 WebUI",
|
||||
"core": "下载项目代码",
|
||||
"dependencies": "正在更新依赖...",
|
||||
"restart": "正在准备重启...",
|
||||
"restarting": "正在重启 AstrBot",
|
||||
"completed": "更新完成",
|
||||
"failed": "更新失败"
|
||||
},
|
||||
"redirectConfirm": {
|
||||
"title": "即将跳转",
|
||||
@@ -75,6 +88,7 @@
|
||||
"currentVersion": "当前版本",
|
||||
"hasNewVersion": "有新版本!",
|
||||
"isLatest": "已经是最新版本了。",
|
||||
"fallback": "完整更新异常时,可仅重新下载 WebUI 作为兜底。",
|
||||
"downloadAndUpdate": "下载并更新"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import axios from "axios";
|
||||
import Logo from "@/components/shared/Logo.vue";
|
||||
@@ -47,10 +47,54 @@ let hasNewVersion = ref(false);
|
||||
let botCurrVersion = ref("");
|
||||
let dashboardHasNewVersion = ref(false);
|
||||
let dashboardCurrentVersion = ref("");
|
||||
let version = ref("");
|
||||
let releases = ref([]);
|
||||
let releasesLoading = ref(false);
|
||||
let updatingDashboardLoading = ref(false);
|
||||
let installLoading = ref(false);
|
||||
let showAdvancedUpdateSettings = ref(false);
|
||||
let restartWaiting = ref(false);
|
||||
let restartStartTime = ref<number | string | null>(null);
|
||||
let restartPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
type DownloadStageStatus = "pending" | "running" | "done" | "error";
|
||||
type DownloadStage = {
|
||||
status: DownloadStageStatus;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
speed: number;
|
||||
};
|
||||
type UpdateProgress = {
|
||||
id: string;
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
stage: string;
|
||||
version: string;
|
||||
message: string;
|
||||
overall_percent: number;
|
||||
stages: Record<string, DownloadStage>;
|
||||
};
|
||||
const createEmptyDownloadStage = (
|
||||
status: DownloadStageStatus = "pending",
|
||||
): DownloadStage => ({
|
||||
status,
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
speed: 0,
|
||||
});
|
||||
const createEmptyUpdateProgress = (): UpdateProgress => ({
|
||||
id: "",
|
||||
status: "idle",
|
||||
stage: "preparing",
|
||||
version: "",
|
||||
message: "",
|
||||
overall_percent: 0,
|
||||
stages: {
|
||||
dashboard: createEmptyDownloadStage(),
|
||||
core: createEmptyDownloadStage(),
|
||||
},
|
||||
});
|
||||
let updateProgress = ref<UpdateProgress>(createEmptyUpdateProgress());
|
||||
let updateProgressTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const isDesktopReleaseMode = ref(
|
||||
typeof window !== "undefined" && !!window.astrbotDesktop?.isDesktop,
|
||||
);
|
||||
@@ -98,9 +142,49 @@ const releasesHeader = computed(() => [
|
||||
key: "published_at",
|
||||
},
|
||||
{ title: t("core.header.updateDialog.table.content"), key: "body" },
|
||||
{ title: t("core.header.updateDialog.table.sourceUrl"), key: "zipball_url" },
|
||||
{ title: t("core.header.updateDialog.table.actions"), key: "switch" },
|
||||
]);
|
||||
const firstReleasePageItems = computed(() => releases.value.slice(0, 6));
|
||||
const firstReleasePageHasPreRelease = computed(() =>
|
||||
firstReleasePageItems.value.some((item: any) => isPreRelease(item.tag_name)),
|
||||
);
|
||||
const updateStageItems = computed(() => [
|
||||
{
|
||||
key: "dashboard",
|
||||
title: t("core.header.updateDialog.progress.dashboard"),
|
||||
progress:
|
||||
updateProgress.value.stages.dashboard || createEmptyDownloadStage(),
|
||||
},
|
||||
{
|
||||
key: "core",
|
||||
title: t("core.header.updateDialog.progress.core"),
|
||||
progress: updateProgress.value.stages.core || createEmptyDownloadStage(),
|
||||
},
|
||||
]);
|
||||
const updateProgressMessage = computed(() => {
|
||||
if (updateProgress.value.status === "error") {
|
||||
return (
|
||||
updateProgress.value.message ||
|
||||
t("core.header.updateDialog.progress.failed")
|
||||
);
|
||||
}
|
||||
if (updateProgress.value.status === "success") {
|
||||
return (
|
||||
updateProgress.value.message ||
|
||||
t("core.header.updateDialog.progress.completed")
|
||||
);
|
||||
}
|
||||
if (updateProgress.value.stage === "dependencies") {
|
||||
return t("core.header.updateDialog.progress.dependencies");
|
||||
}
|
||||
if (updateProgress.value.stage === "restart") {
|
||||
return t("core.header.updateDialog.progress.restart");
|
||||
}
|
||||
return (
|
||||
updateProgress.value.message ||
|
||||
t("core.header.updateDialog.progress.preparing")
|
||||
);
|
||||
});
|
||||
// Form validation
|
||||
const formValid = ref(true);
|
||||
const passwordRules = computed(() => [
|
||||
@@ -262,7 +346,9 @@ function accountEdit() {
|
||||
|
||||
const currentPasswordValue = password.value ? password.value : "";
|
||||
const newPasswordValue = newPassword.value ? newPassword.value : "";
|
||||
const confirmPasswordValue = confirmPassword.value ? confirmPassword.value : "";
|
||||
const confirmPasswordValue = confirmPassword.value
|
||||
? confirmPassword.value
|
||||
: "";
|
||||
|
||||
axios
|
||||
.post("/api/auth/account/edit", {
|
||||
@@ -324,7 +410,10 @@ function getVersion() {
|
||||
accountWarningUpgrade.value = !!password_upgrade_required;
|
||||
accountWarningLegacy.value =
|
||||
!!legacy_pwd_hint && !password_upgrade_required;
|
||||
if (change_pwd_hint || (legacy_pwd_hint && !password_upgrade_required)) {
|
||||
if (
|
||||
change_pwd_hint ||
|
||||
(legacy_pwd_hint && !password_upgrade_required)
|
||||
) {
|
||||
localStorage.setItem("change_pwd_hint", "true");
|
||||
} else {
|
||||
localStorage.removeItem("change_pwd_hint");
|
||||
@@ -396,6 +485,7 @@ function checkUpdate() {
|
||||
}
|
||||
|
||||
function getReleases() {
|
||||
releasesLoading.value = true;
|
||||
return axios
|
||||
.get("/api/update/releases")
|
||||
.then((res) => {
|
||||
@@ -406,31 +496,210 @@ function getReleases() {
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
})
|
||||
.finally(() => {
|
||||
releasesLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function switchVersion(version: string) {
|
||||
function formatDownloadSize(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
return "-";
|
||||
}
|
||||
if (value < 1024 * 1024) {
|
||||
return `${(value / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function formatDownloadSpeed(value: number) {
|
||||
if (!value || value <= 0) {
|
||||
return "-";
|
||||
}
|
||||
if (value < 1024) {
|
||||
return `${value.toFixed(1)} KB/s`;
|
||||
}
|
||||
return `${(value / 1024).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function getStageStatusColor(status: DownloadStageStatus) {
|
||||
if (status === "done") {
|
||||
return "success";
|
||||
}
|
||||
if (status === "running") {
|
||||
return "primary";
|
||||
}
|
||||
if (status === "error") {
|
||||
return "error";
|
||||
}
|
||||
return "grey";
|
||||
}
|
||||
|
||||
function getStageStatusIcon(status: DownloadStageStatus) {
|
||||
if (status === "done") {
|
||||
return "mdi-check-circle";
|
||||
}
|
||||
if (status === "running") {
|
||||
return "mdi-progress-download";
|
||||
}
|
||||
if (status === "error") {
|
||||
return "mdi-alert-circle";
|
||||
}
|
||||
return "mdi-circle-outline";
|
||||
}
|
||||
|
||||
function stopUpdateProgressPolling() {
|
||||
if (updateProgressTimer) {
|
||||
clearInterval(updateProgressTimer);
|
||||
updateProgressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRestartPolling() {
|
||||
if (restartPollTimer) {
|
||||
clearInterval(restartPollTimer);
|
||||
restartPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAstrBotStartTime() {
|
||||
const res = await axios.get("/api/stat/start-time", { timeout: 3000 });
|
||||
const startTime = res.data?.data?.start_time ?? null;
|
||||
commonStore.startTime = startTime;
|
||||
return startTime;
|
||||
}
|
||||
|
||||
function waitForAstrBotRestart(initialStartTime: number | string | null) {
|
||||
if (restartWaiting.value) {
|
||||
return;
|
||||
}
|
||||
stopRestartPolling();
|
||||
restartWaiting.value = true;
|
||||
restartStartTime.value = initialStartTime;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
stage: "restart",
|
||||
status: "success",
|
||||
message: t("core.header.updateDialog.progress.restarting"),
|
||||
overall_percent: 100,
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const currentStartTime = await fetchAstrBotStartTime();
|
||||
if (
|
||||
initialStartTime !== null &&
|
||||
currentStartTime !== null &&
|
||||
currentStartTime !== initialStartTime
|
||||
) {
|
||||
stopRestartPolling();
|
||||
restartWaiting.value = false;
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (_error) {
|
||||
// Backend may be unavailable while the process is restarting.
|
||||
}
|
||||
};
|
||||
|
||||
restartPollTimer = setInterval(() => {
|
||||
void poll();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function applyUpdateProgress(payload: UpdateProgress) {
|
||||
updateProgress.value = {
|
||||
...createEmptyUpdateProgress(),
|
||||
...payload,
|
||||
stages: {
|
||||
...createEmptyUpdateProgress().stages,
|
||||
...(payload.stages || {}),
|
||||
},
|
||||
};
|
||||
if (payload.status === "success" || payload.status === "error") {
|
||||
stopUpdateProgressPolling();
|
||||
}
|
||||
if (payload.status === "success") {
|
||||
waitForAstrBotRestart(restartStartTime.value);
|
||||
}
|
||||
}
|
||||
|
||||
function startUpdateProgressPolling(progressId: string) {
|
||||
stopUpdateProgressPolling();
|
||||
const poll = () => {
|
||||
axios
|
||||
.get("/api/update/progress", { params: { id: progressId } })
|
||||
.then((res) => {
|
||||
if (res.data?.data) {
|
||||
applyUpdateProgress(res.data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
};
|
||||
poll();
|
||||
updateProgressTimer = setInterval(poll, 800);
|
||||
}
|
||||
|
||||
async function switchVersion(targetVersion: string) {
|
||||
const progressId =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
let initialStartTime: number | string | null = null;
|
||||
updateProgress.value = {
|
||||
...createEmptyUpdateProgress(),
|
||||
id: progressId,
|
||||
status: "running",
|
||||
version: targetVersion,
|
||||
message: t("core.header.updateDialog.progress.preparing"),
|
||||
} as UpdateProgress;
|
||||
updateStatus.value = t("core.header.updateDialog.status.switching");
|
||||
installLoading.value = true;
|
||||
|
||||
try {
|
||||
initialStartTime = await fetchAstrBotStartTime();
|
||||
} catch (_error) {
|
||||
initialStartTime = commonStore.getStartTime();
|
||||
}
|
||||
restartStartTime.value = initialStartTime;
|
||||
startUpdateProgressPolling(progressId);
|
||||
|
||||
axios
|
||||
.post("/api/update/do", {
|
||||
version: version,
|
||||
version: targetVersion,
|
||||
proxy: getSelectedGitHubProxy(),
|
||||
progress_id: progressId,
|
||||
})
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
status:
|
||||
res.data.status === "ok" ? "success" : updateProgress.value.status,
|
||||
message: res.data.message,
|
||||
overall_percent:
|
||||
res.data.status === "ok" ? 100 : updateProgress.value.overall_percent,
|
||||
};
|
||||
if (res.data.status == "ok") {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
waitForAstrBotRestart(initialStartTime);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err;
|
||||
updateProgress.value = {
|
||||
...updateProgress.value,
|
||||
status: "error",
|
||||
message:
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
t("core.header.updateDialog.progress.failed"),
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
installLoading.value = false;
|
||||
stopUpdateProgressPolling();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -486,6 +755,11 @@ initPasswordWarningFromStorage();
|
||||
commonStore.createEventSource(); // log
|
||||
commonStore.getStartTime();
|
||||
|
||||
onUnmounted(() => {
|
||||
stopUpdateProgressPolling();
|
||||
stopRestartPolling();
|
||||
});
|
||||
|
||||
// 视图模式切换
|
||||
onMounted(() => {
|
||||
// 初次加載時保存當前路由
|
||||
@@ -865,12 +1139,14 @@ onMounted(async () => {
|
||||
<!-- 更新对话框 -->
|
||||
<v-dialog
|
||||
v-model="updateStatusDialog"
|
||||
:width="$vuetify.display.smAndDown ? '100%' : '1200'"
|
||||
:width="$vuetify.display.smAndDown ? '100%' : '920'"
|
||||
:fullscreen="$vuetify.display.xs"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="mobile-card-title">
|
||||
<span class="text-h5">{{ t("core.header.updateDialog.title") }}</span>
|
||||
<span class="text-h3 pa-4">{{
|
||||
t("core.header.updateDialog.title")
|
||||
}}</span>
|
||||
<v-btn
|
||||
v-if="$vuetify.display.xs"
|
||||
icon
|
||||
@@ -881,62 +1157,142 @@ onMounted(async () => {
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-progress-linear
|
||||
v-show="installLoading"
|
||||
class="mb-4"
|
||||
indeterminate
|
||||
color="primary"
|
||||
></v-progress-linear>
|
||||
|
||||
<div>
|
||||
<h1 style="display: inline-block">{{ botCurrVersion }}</h1>
|
||||
<small style="margin-left: 4px">{{ updateStatus }}</small>
|
||||
<div class="update-summary">
|
||||
<div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ t("core.header.updateDialog.currentVersion") }}
|
||||
</div>
|
||||
<div class="text-h2 font-weight-bold">{{ botCurrVersion }}</div>
|
||||
</div>
|
||||
<v-chip
|
||||
:color="hasNewVersion ? 'primary' : 'success'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{
|
||||
hasNewVersion
|
||||
? t("core.header.version.hasNewVersion")
|
||||
: updateStatus
|
||||
}}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="releaseMessage"
|
||||
style="
|
||||
background-color: #646cff24;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
"
|
||||
v-if="installLoading || updateProgress.status !== 'idle'"
|
||||
class="update-progress-panel mt-5"
|
||||
>
|
||||
<MarkdownRender
|
||||
:content="releaseMessage"
|
||||
:typewriter="false"
|
||||
class="markdown-content"
|
||||
/>
|
||||
<div v-if="restartWaiting" class="restart-waiting-panel">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="primary"
|
||||
size="42"
|
||||
width="4"
|
||||
></v-progress-circular>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ t("core.header.updateDialog.progress.restarting") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
{{ updateProgressMessage }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ t("core.header.updateDialog.progress.target") }}
|
||||
{{ updateProgress.version || "latest" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-h6 font-weight-bold">
|
||||
{{ updateProgress.overall_percent }}%
|
||||
</div>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="updateProgress.overall_percent"
|
||||
height="8"
|
||||
rounded
|
||||
color="primary"
|
||||
></v-progress-linear>
|
||||
|
||||
<div class="update-stage-list mt-4">
|
||||
<div
|
||||
v-for="stage in updateStageItems"
|
||||
:key="stage.key"
|
||||
class="update-stage-row"
|
||||
>
|
||||
<v-icon
|
||||
:icon="getStageStatusIcon(stage.progress.status)"
|
||||
:color="getStageStatusColor(stage.progress.status)"
|
||||
size="22"
|
||||
></v-icon>
|
||||
<div class="update-stage-content">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<span class="font-weight-medium">{{
|
||||
stage.title
|
||||
}}</span>
|
||||
<span class="text-caption">
|
||||
{{ stage.progress.percent }}%
|
||||
</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:model-value="stage.progress.percent"
|
||||
height="5"
|
||||
rounded
|
||||
:color="getStageStatusColor(stage.progress.status)"
|
||||
class="mt-2"
|
||||
></v-progress-linear>
|
||||
<div class="update-stage-meta">
|
||||
<span>
|
||||
{{ formatDownloadSize(stage.progress.downloaded) }} /
|
||||
{{ formatDownloadSize(stage.progress.total) }}
|
||||
</span>
|
||||
<span>{{
|
||||
formatDownloadSpeed(stage.progress.speed)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-4">
|
||||
<small
|
||||
>{{ t("core.header.updateDialog.tip") }}
|
||||
{{ t("core.header.updateDialog.tipContinue") }}</small
|
||||
>
|
||||
<div v-if="releaseMessage && !installLoading" class="mt-5">
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<h3 class="text-subtitle-1 font-weight-medium">
|
||||
{{ t("core.header.updateDialog.releaseNotes.title") }}
|
||||
</h3>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="
|
||||
openReleaseNotesDialog(
|
||||
releaseMessage,
|
||||
t('core.header.updateDialog.releaseNotes.latestLabel'),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ t("core.header.updateDialog.table.view") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="release-message-preview">
|
||||
<MarkdownRender
|
||||
:content="releaseMessage"
|
||||
:typewriter="false"
|
||||
class="markdown-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发行版 -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<small
|
||||
>{{ t("core.header.updateDialog.dockerTip") }}
|
||||
<a href="https://containrrr.dev/watchtower/usage-overview/">{{
|
||||
t("core.header.updateDialog.dockerTipLink")
|
||||
}}</a>
|
||||
{{ t("core.header.updateDialog.dockerTipContinue") }}</small
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<v-alert
|
||||
v-if="
|
||||
releases.some((item: any) => isPreRelease(item['tag_name']))
|
||||
"
|
||||
v-if="!installLoading && firstReleasePageHasPreRelease"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-alert-circle-outline</v-icon>
|
||||
@@ -965,7 +1321,9 @@ onMounted(async () => {
|
||||
:headers="releasesHeader"
|
||||
:items="releases"
|
||||
item-key="name"
|
||||
:items-per-page="8"
|
||||
:items-per-page="6"
|
||||
density="comfortable"
|
||||
:loading="releasesLoading"
|
||||
>
|
||||
<template v-slot:item.tag_name="{ item }: { item: any }">
|
||||
<div class="d-flex align-center">
|
||||
@@ -1002,9 +1360,10 @@ onMounted(async () => {
|
||||
>
|
||||
<v-btn
|
||||
@click="switchVersion(item.tag_name)"
|
||||
rounded="xl"
|
||||
variant="plain"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
:disabled="installLoading"
|
||||
>
|
||||
{{ t("core.header.updateDialog.table.switch") }}
|
||||
</v-btn>
|
||||
@@ -1012,45 +1371,66 @@ onMounted(async () => {
|
||||
</v-data-table>
|
||||
</div>
|
||||
|
||||
<v-divider class="mt-4 mb-4"></v-divider>
|
||||
<div style="margin-top: 16px">
|
||||
<h3 class="mb-4">
|
||||
{{ t("core.header.updateDialog.dashboardUpdate.title") }}
|
||||
</h3>
|
||||
<div class="mb-4">
|
||||
<small
|
||||
>{{
|
||||
t("core.header.updateDialog.dashboardUpdate.currentVersion")
|
||||
}}
|
||||
{{ dashboardCurrentVersion }}</small
|
||||
>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p v-if="dashboardHasNewVersion">
|
||||
{{
|
||||
t("core.header.updateDialog.dashboardUpdate.hasNewVersion")
|
||||
}}
|
||||
</p>
|
||||
<p v-else="dashboardHasNewVersion">
|
||||
{{ t("core.header.updateDialog.dashboardUpdate.isLatest") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
style="border-radius: 10px"
|
||||
@click="updateDashboard()"
|
||||
:disabled="!dashboardHasNewVersion"
|
||||
:loading="updatingDashboardLoading"
|
||||
<div v-if="!installLoading" class="advanced-update-settings mt-5">
|
||||
<button
|
||||
class="advanced-settings-toggle"
|
||||
type="button"
|
||||
@click="
|
||||
showAdvancedUpdateSettings = !showAdvancedUpdateSettings
|
||||
"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"core.header.updateDialog.dashboardUpdate.downloadAndUpdate",
|
||||
)
|
||||
}}
|
||||
</v-btn>
|
||||
<span>{{
|
||||
t("core.header.updateDialog.advancedSettings")
|
||||
}}</span>
|
||||
<v-icon
|
||||
:icon="
|
||||
showAdvancedUpdateSettings
|
||||
? 'mdi-chevron-down'
|
||||
: 'mdi-chevron-right'
|
||||
"
|
||||
size="20"
|
||||
></v-icon>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="showAdvancedUpdateSettings"
|
||||
class="dashboard-update-banner mt-3"
|
||||
>
|
||||
<div>
|
||||
<div class="font-weight-medium">
|
||||
{{ t("core.header.updateDialog.dashboardUpdate.title") }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{
|
||||
t(
|
||||
"core.header.updateDialog.dashboardUpdate.currentVersion",
|
||||
)
|
||||
}}
|
||||
{{ dashboardCurrentVersion }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{
|
||||
dashboardHasNewVersion
|
||||
? t(
|
||||
"core.header.updateDialog.dashboardUpdate.hasNewVersion",
|
||||
)
|
||||
: t("core.header.updateDialog.dashboardUpdate.fallback")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="updateDashboard()"
|
||||
:loading="updatingDashboardLoading"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
"core.header.updateDialog.dashboardUpdate.downloadAndUpdate",
|
||||
)
|
||||
}}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
@@ -1070,7 +1450,7 @@ onMounted(async () => {
|
||||
<!-- Release Notes Modal -->
|
||||
<v-dialog v-model="releaseNotesDialog" max-width="800">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">
|
||||
<v-card-title class="text-h3 pa-4">
|
||||
{{ t("core.header.updateDialog.releaseNotes.title") }}:
|
||||
{{ selectedReleaseTag }}
|
||||
</v-card-title>
|
||||
@@ -1463,6 +1843,102 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.update-summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.update-progress-panel,
|
||||
.dashboard-update-banner,
|
||||
.release-message-preview {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.release-message-preview {
|
||||
max-height: 220px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.release-message-preview::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 56px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-surface), 0),
|
||||
rgb(var(--v-theme-surface))
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dashboard-update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.advanced-settings-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 500;
|
||||
padding: 8px 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.advanced-settings-toggle:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.restart-waiting-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 18px 0 22px;
|
||||
}
|
||||
|
||||
.update-stage-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.update-stage-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.update-stage-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-stage-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移动端样式优化 */
|
||||
@media (max-width: 600px) {
|
||||
.logo-text {
|
||||
@@ -1501,5 +1977,11 @@ onMounted(async () => {
|
||||
.v-btn-toggle .v-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.update-summary,
|
||||
.dashboard-update-banner {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+20
-1
@@ -1738,13 +1738,22 @@ async def test_do_update(
|
||||
# Use a temporary path for the mock update to avoid side effects
|
||||
temp_release_dir = tmp_path_factory.mktemp("release")
|
||||
release_path = temp_release_dir / "astrbot"
|
||||
calls = []
|
||||
|
||||
async def mock_update(*args, **kwargs):
|
||||
"""Mocks the update process by creating a directory in the temp path."""
|
||||
calls.append("core")
|
||||
callback = kwargs.get("progress_callback")
|
||||
if callback:
|
||||
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
|
||||
os.makedirs(release_path, exist_ok=True)
|
||||
|
||||
async def mock_download_dashboard(*args, **kwargs):
|
||||
"""Mocks the dashboard download to prevent network access."""
|
||||
calls.append("dashboard")
|
||||
callback = kwargs.get("progress_callback")
|
||||
if callback:
|
||||
callback({"downloaded": 10, "total": 10, "percent": 1, "speed": 1})
|
||||
return
|
||||
|
||||
async def mock_pip_install(*args, **kwargs):
|
||||
@@ -1764,12 +1773,22 @@ async def test_do_update(
|
||||
response = await test_client.post(
|
||||
"/api/update/do",
|
||||
headers=authenticated_header,
|
||||
json={"version": "v3.4.0", "reboot": False},
|
||||
json={"version": "v3.4.0", "reboot": False, "progress_id": "test-progress"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert os.path.exists(release_path)
|
||||
assert calls[:2] == ["dashboard", "core"]
|
||||
|
||||
progress_response = await test_client.get(
|
||||
"/api/update/progress?id=test-progress",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
progress_data = await progress_response.get_json()
|
||||
assert progress_data["status"] == "ok"
|
||||
assert progress_data["data"]["status"] == "success"
|
||||
assert progress_data["data"]["overall_percent"] == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user