From 7e0c1fecb8adfb75ec0427266694a08bac0a92a5 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 02:33:19 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=EF=BB=BFfeat(update):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Docker=20=E5=9C=BA=E6=99=AF=E4=B8=80=E9=94=AE=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=B9=B6=E6=8E=A5=E5=85=A5=20Release=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次版本主要更新: - 新增应用内一键更新:在系统设置页增加“应用更新(Docker)”,支持检查新版本、匹配当前架构更新包、点击后自动下载并部署。 - 新增自更新服务 AppSelfUpdateService:基于 GitHub Release 读取版本与资产,执行下载、解压、目录切换(app-current/app-previous)并触发程序重启。 - 新增 SelfUpdateOptions 配置:支持开关、工作目录、Docker-only 限制、下载超时与重启延迟等参数化控制。 - Docker 启动链路改造:新增 entrypoint 脚本,容器重启后优先从 /data/app-current 启动更新版本,并统一挂载 /data/logs 与 /data/appsettings.local.json。 - Release 工作流增强:自动发布时同时构建并上传 linux-x64/linux-arm64 zip 资产,为面板内一键更新提供标准下载源。 - CI 触发范围补齐:Docker workflow 监听 docker 目录变更,确保 entrypoint 变更可触发镜像构建验证。 - 更新检查兼容性修复:为 UpdateCheckService 的 GitHub 字段增加 JsonPropertyName 映射,确保 tag/release 信息解析稳定。 - README 补充自动更新文档:新增面板一键更新流程、资产依赖与使用限制说明。 --- .github/workflows/docker.yml | 1 + .github/workflows/release.yml | 57 +- Dockerfile | 4 +- README.md | 21 +- docker/entrypoint.sh | 35 ++ .../Components/Pages/Settings.razor | 185 ++++++ src/TelegramPanel.Web/Program.cs | 2 + .../Services/AppSelfUpdateService.cs | 564 ++++++++++++++++++ .../Services/SelfUpdateOptions.cs | 44 ++ .../Services/UpdateCheckService.cs | 8 + 10 files changed, 916 insertions(+), 5 deletions(-) create mode 100644 docker/entrypoint.sh create mode 100644 src/TelegramPanel.Web/Services/AppSelfUpdateService.cs create mode 100644 src/TelegramPanel.Web/Services/SelfUpdateOptions.cs diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3d7050a..d323fe5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,6 +14,7 @@ on: paths: - "Dockerfile" - "Dockerfile.local" + - "docker/**" - "docker-compose.yml" - "src/**" - ".github/workflows/docker.yml" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9668b0..9d98850 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,6 +33,54 @@ jobs: fi echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + - name: Checkout source at tag + uses: actions/checkout@v4 + with: + ref: ${{ steps.vars.outputs.tag }} + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore + run: dotnet restore TelegramPanel.sln + + - name: Publish Linux update packages + shell: bash + run: | + set -euo pipefail + dotnet publish src/TelegramPanel.Web/TelegramPanel.Web.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained false \ + -p:UseAppHost=false \ + -o artifacts/linux-x64 + + dotnet publish src/TelegramPanel.Web/TelegramPanel.Web.csproj \ + -c Release \ + -r linux-arm64 \ + --self-contained false \ + -p:UseAppHost=false \ + -o artifacts/linux-arm64 + + - name: Pack release assets + id: pack + shell: bash + run: | + set -euo pipefail + TAG="${{ steps.vars.outputs.tag }}" + mkdir -p dist + + X64_ZIP="telegram-panel-${TAG}-linux-x64.zip" + ARM64_ZIP="telegram-panel-${TAG}-linux-arm64.zip" + + (cd artifacts/linux-x64 && zip -rq "../../dist/${X64_ZIP}" .) + (cd artifacts/linux-arm64 && zip -rq "../../dist/${ARM64_ZIP}" .) + + echo "x64=dist/${X64_ZIP}" >> "$GITHUB_OUTPUT" + echo "arm64=dist/${ARM64_ZIP}" >> "$GITHUB_OUTPUT" + - name: Create GitHub Release with auto changelog uses: softprops/action-gh-release@v2 with: @@ -40,6 +88,9 @@ jobs: generate_release_notes: true append_body: true prerelease: ${{ contains(steps.vars.outputs.tag, '-') }} + files: | + ${{ steps.pack.outputs.x64 }} + ${{ steps.pack.outputs.arm64 }} body: | ## Docker 镜像 @@ -47,5 +98,9 @@ jobs: docker pull ghcr.io/${{ github.repository }}:${{ steps.vars.outputs.tag }} ``` - > 提示:该版本对应的多架构镜像由 `Docker Build & Publish` 工作流自动构建并推送。 + ## 应用内一键更新资产 + - `${{ steps.pack.outputs.x64 }}` + - `${{ steps.pack.outputs.arm64 }}` + + > 提示:面板内「系统设置 -> 应用更新(Docker)」会自动匹配并下载对应架构的更新包。 diff --git a/Dockerfile b/Dockerfile index 645657c..86ec230 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,10 @@ RUN mkdir -p /data /data/sessions /data/logs \ COPY --from=build /app/publish . COPY --from=tdata-runtime /usr/local /usr/local COPY --from=tdata-runtime /opt/telegram-panel-tdata-runtime /app/tdata-runtime +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh RUN node -v && npm -v -ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index d435814..2918233 100644 --- a/README.md +++ b/README.md @@ -101,13 +101,13 @@ docker compose up -d --build ```bash # 稳定版 -docker pull ghcr.io//telegram-panel:latest +docker pull ghcr.io/moeacgx/telegram-panel:latest # 开发版 -docker pull ghcr.io//telegram-panel:dev-latest +docker pull ghcr.io/moeacgx/telegram-panel:dev-latest # 指定版本 -docker pull ghcr.io//telegram-panel:v1.2.3 +docker pull ghcr.io/moeacgx/telegram-panel:v1.2.3 ``` ## 自动发布与 Changelog @@ -117,6 +117,9 @@ docker pull ghcr.io//telegram-panel:v1.2.3 触发规则: - 推送 `v*` tag(如 `v1.2.3`)后,自动创建 GitHub Release - Release 内容自动生成 changelog(基于提交/PR),并读取 `.github/release.yml` 分类规则 +- 同时自动打包并上传 Linux 更新包资产: + - `telegram-panel-vX.Y.Z-linux-x64.zip` + - `telegram-panel-vX.Y.Z-linux-arm64.zip` 发布示例: @@ -127,6 +130,18 @@ git tag v1.2.3 git push origin v1.2.3 ``` +## Docker 一键更新(面板内) + +面板已支持在 Docker 部署场景下一键更新(`系统设置 -> 应用更新(Docker)`): + +1. 点击“检查更新”,读取 GitHub 最新 Release。 +2. 点击“一键更新并重启”,自动下载对应架构的 Linux 更新包到 `/data/app-current`。 +3. 程序触发重启后,容器会优先从 `/data/app-current` 启动新版本(无需手动 `docker compose pull`)。 + +说明: +- 当前仅支持 Docker 容器内执行一键更新。 +- 更新资产依赖 `release.yml` 工作流产物;若 Release 没有 `linux-x64/linux-arm64` zip 资产,则一键更新会提示不可用。 + ## 截图 更多截图见:`screenshot/` diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..4e493a4 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env sh +set -eu + +APP_ENTRY="TelegramPanel.Web.dll" +DEFAULT_APP_DIR="/app" +UPDATED_APP_DIR="/data/app-current" + +mkdir -p /data /data/sessions /data/logs +if [ ! -f /data/appsettings.local.json ]; then + printf '{}' > /data/appsettings.local.json +fi + +APP_DIR="$DEFAULT_APP_DIR" +if [ -f "$UPDATED_APP_DIR/$APP_ENTRY" ]; then + APP_DIR="$UPDATED_APP_DIR" +fi + +# 运行目录下日志统一指向 /data/logs,避免更新目录轮换后日志丢失。 +if [ -e "$APP_DIR/logs" ] && [ ! -L "$APP_DIR/logs" ]; then + rm -rf "$APP_DIR/logs" +fi +if [ ! -e "$APP_DIR/logs" ]; then + ln -s /data/logs "$APP_DIR/logs" || true +fi + +# 面板保存的本地配置统一使用 /data/appsettings.local.json +if [ -e "$APP_DIR/appsettings.local.json" ] && [ ! -L "$APP_DIR/appsettings.local.json" ]; then + rm -f "$APP_DIR/appsettings.local.json" +fi +if [ ! -e "$APP_DIR/appsettings.local.json" ]; then + ln -s /data/appsettings.local.json "$APP_DIR/appsettings.local.json" || true +fi + +cd "$APP_DIR" +exec dotnet "$APP_ENTRY" diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 2aa8400..80ec082 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -13,6 +13,7 @@ @inject PanelTimeZoneService TimeZone @inject NavigationManager Navigation @inject CloudMailClient CloudMail +@inject AppSelfUpdateService SelfUpdate @using TelegramPanel.Web.Services @using TelegramPanel.Core.Utils @@ -200,6 +201,98 @@ + + + 应用更新(Docker) + + + + 一键更新会下载 GitHub Release 的 Linux 更新包,写入 /data/app-current,并自动重启服务。 + + + 当前版本:v@VersionService.Version + + + 运行环境:@(SelfUpdate.IsDockerEnvironment ? "Docker" : "非 Docker") + + + @if (selfUpdateInfo != null) + { + + + @if (!selfUpdateInfo.Success) + { + + 检查失败:@selfUpdateInfo.Error + + } + else if (!selfUpdateInfo.Enabled) + { + + 自动更新已禁用 + + } + else if (selfUpdateInfo.UpdateAvailable) + { + + 发现新版本:v@selfUpdateInfo.LatestVersion(当前 v@selfUpdateInfo.CurrentVersion) + + + 更新包:@(selfUpdateInfo.AssetName ?? "未匹配") + + @if (!string.IsNullOrWhiteSpace(selfUpdateInfo.BlockedReason)) + { + + @selfUpdateInfo.BlockedReason + + } + } + else + { + + 当前已是最新版本 + + } + + @if (!string.IsNullOrWhiteSpace(selfUpdateInfo.Url)) + { + + 查看发布页 + + } + + + 最近检查:@selfUpdateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") + + } + + + + @if (selfUpdateChecking) + { + + } + 检查更新 + + + @if (selfUpdateApplying) + { + + } + 一键更新并重启 + + + + 日志设置 @@ -272,6 +365,9 @@ private string cloudMailAdminEmail = ""; private string cloudMailAdminPassword = ""; private bool cloudMailGeneratingToken = false; + private bool selfUpdateChecking = false; + private bool selfUpdateApplying = false; + private AppSelfUpdateInfo? selfUpdateInfo; private sealed record TimeZoneChoice(string Id, string Label); @@ -294,6 +390,15 @@ } } + private bool CanApplySelfUpdate => + selfUpdateInfo is + { + Success: true, + Enabled: true, + UpdateAvailable: true, + CanApply: true + }; + private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, Environment); private bool LocalConfigExists => System.IO.File.Exists(LocalConfigPath); @@ -650,6 +755,86 @@ } } + private async Task CheckSelfUpdate() + { + if (selfUpdateChecking) + return; + + selfUpdateChecking = true; + try + { + selfUpdateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true, CancellationToken.None); + if (!selfUpdateInfo.Success) + { + Snackbar.Add($"检查更新失败:{selfUpdateInfo.Error}", Severity.Warning); + return; + } + + if (!selfUpdateInfo.Enabled) + { + Snackbar.Add("自动更新功能当前已禁用", Severity.Info); + return; + } + + if (selfUpdateInfo.UpdateAvailable) + Snackbar.Add($"发现新版本:v{selfUpdateInfo.LatestVersion}", Severity.Info); + else + Snackbar.Add("当前已是最新版本", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"检查更新异常:{ex.Message}", Severity.Error); + } + finally + { + selfUpdateChecking = false; + } + } + + private async Task ApplySelfUpdate() + { + if (selfUpdateApplying) + return; + + if (selfUpdateInfo == null || !CanApplySelfUpdate) + { + Snackbar.Add(selfUpdateInfo?.BlockedReason ?? "请先检查更新并确认可更新", Severity.Warning); + return; + } + + var target = selfUpdateInfo.LatestTag ?? selfUpdateInfo.LatestVersion ?? "最新版本"; + bool? ok = await DialogService.ShowMessageBox( + "确认一键更新", + $"将升级到 {target},并在部署完成后自动重启服务。是否继续?", + yesText: "继续", + cancelText: "取消"); + + if (ok != true) + return; + + selfUpdateApplying = true; + try + { + var result = await SelfUpdate.ApplyLatestAsync(CancellationToken.None); + if (result.Success) + { + Snackbar.Add(result.Message, Severity.Success); + return; + } + + Snackbar.Add(result.Message, Severity.Error); + selfUpdateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true, CancellationToken.None); + } + catch (Exception ex) + { + Snackbar.Add($"执行更新异常:{ex.Message}", Severity.Error); + } + finally + { + selfUpdateApplying = false; + } + } + private async Task ClearCache() { bool? result = await DialogService.ShowMessageBox( diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index b190113..c155e40 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -409,7 +409,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection("UpdateCheck")); +builder.Services.Configure(builder.Configuration.GetSection("SelfUpdate")); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("System")); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/TelegramPanel.Web/Services/AppSelfUpdateService.cs b/src/TelegramPanel.Web/Services/AppSelfUpdateService.cs new file mode 100644 index 0000000..085b912 --- /dev/null +++ b/src/TelegramPanel.Web/Services/AppSelfUpdateService.cs @@ -0,0 +1,564 @@ +using System.IO.Compression; +using System.Net; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +namespace TelegramPanel.Web.Services; + +public sealed class AppSelfUpdateService +{ + private const string CacheKeyPrefix = "self-update:"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly IOptionsMonitor _updateOptions; + private readonly IOptionsMonitor _selfUpdateOptions; + private readonly IWebHostEnvironment _environment; + private readonly AppRestartService _restartService; + private readonly ILogger _logger; + private readonly SemaphoreSlim _applyLock = new(1, 1); + + public AppSelfUpdateService( + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + IOptionsMonitor updateOptions, + IOptionsMonitor selfUpdateOptions, + IWebHostEnvironment environment, + AppRestartService restartService, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _cache = cache; + _updateOptions = updateOptions; + _selfUpdateOptions = selfUpdateOptions; + _environment = environment; + _restartService = restartService; + _logger = logger; + } + + public bool IsDockerEnvironment => IsRunningInDocker(); + + public async Task CheckLatestAsync(bool forceRefresh = false, CancellationToken cancellationToken = default) + { + var currentVersion = VersionService.Version; + var updateOptions = _updateOptions.CurrentValue; + var selfUpdateOptions = _selfUpdateOptions.CurrentValue; + var isDocker = IsRunningInDocker(); + + if (!selfUpdateOptions.Enabled) + { + return AppSelfUpdateInfo.Disabled(currentVersion, isDocker, "自动更新功能未启用"); + } + + var repo = (updateOptions.Repository ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(repo) || !repo.Contains('/')) + { + return AppSelfUpdateInfo.Failed(currentVersion, isDocker, "UpdateCheck:Repository 配置无效(应为 owner/repo)"); + } + + if (!TryGetAssetKeyword(RuntimeInformation.ProcessArchitecture, out var assetKeyword, out var archLabel)) + { + return AppSelfUpdateInfo.Failed(currentVersion, isDocker, $"当前架构不支持自动更新:{RuntimeInformation.ProcessArchitecture}"); + } + + var cacheKey = $"{CacheKeyPrefix}{repo}:{assetKeyword}"; + if (!forceRefresh && _cache.TryGetValue(cacheKey, out var cachedObj) && cachedObj is AppSelfUpdateInfo cached) + return cached; + + AppSelfUpdateInfo result; + try + { + var release = await FetchLatestReleaseAsync(repo, cancellationToken); + if (release == null || string.IsNullOrWhiteSpace(release.TagName)) + { + result = AppSelfUpdateInfo.Failed(currentVersion, isDocker, "仓库未找到可用 Release,无法执行一键更新"); + } + else + { + var latestVersion = ParseVersionFromTag(release.TagName); + if (latestVersion == null) + { + result = AppSelfUpdateInfo.Failed(currentVersion, isDocker, $"解析版本失败(tag={release.TagName})"); + } + else + { + var updateAvailable = CompareVersions(latestVersion.Value, currentVersion) > 0; + var matchedAsset = PickAsset(release.Assets, assetKeyword); + var blockedReason = ResolveBlockedReason( + updateAvailable: updateAvailable, + assetFound: matchedAsset != null, + isDocker: isDocker, + dockerOnly: selfUpdateOptions.DockerOnly, + archLabel: archLabel); + + var notes = NormalizeNotes(release.Body); + + result = new AppSelfUpdateInfo + { + Success = true, + Enabled = true, + CurrentVersion = currentVersion, + LatestTag = release.TagName, + LatestVersion = latestVersion.Value.ToString(), + UpdateAvailable = updateAvailable, + Url = release.HtmlUrl, + PublishedAt = release.PublishedAt, + Notes = notes, + IsDocker = isDocker, + AssetName = matchedAsset?.Name, + AssetSizeBytes = matchedAsset?.Size, + AssetDownloadUrl = matchedAsset?.BrowserDownloadUrl, + CanApply = blockedReason == null, + BlockedReason = blockedReason, + CheckedAtUtc = DateTimeOffset.UtcNow + }; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Self update check failed"); + result = AppSelfUpdateInfo.Failed(currentVersion, isDocker, ex.Message); + } + + var cacheMinutes = updateOptions.CacheMinutes <= 0 ? 30 : updateOptions.CacheMinutes; + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(cacheMinutes) + }); + + return result; + } + + public async Task ApplyLatestAsync(CancellationToken cancellationToken = default) + { + var options = _selfUpdateOptions.CurrentValue; + if (!options.Enabled) + return AppSelfUpdateApplyResult.Failed("自动更新功能未启用"); + + if (options.DockerOnly && !IsRunningInDocker()) + return AppSelfUpdateApplyResult.Failed("当前不是 Docker 运行环境,已禁止一键更新"); + + if (!await _applyLock.WaitAsync(0, cancellationToken)) + return AppSelfUpdateApplyResult.Failed("已有更新任务在执行,请稍后重试"); + + try + { + var check = await CheckLatestAsync(forceRefresh: true, cancellationToken); + if (!check.Success) + return AppSelfUpdateApplyResult.Failed($"检查更新失败:{check.Error}"); + + if (!check.UpdateAvailable) + return AppSelfUpdateApplyResult.Failed($"当前已是最新版本(v{check.CurrentVersion})"); + + if (!check.CanApply || string.IsNullOrWhiteSpace(check.AssetDownloadUrl) || string.IsNullOrWhiteSpace(check.AssetName)) + { + var reason = check.BlockedReason ?? "未找到匹配当前架构的更新包"; + return AppSelfUpdateApplyResult.Failed(reason); + } + + var workRoot = ResolveWorkRoot(options); + Directory.CreateDirectory(workRoot); + + var workspaceDir = Path.Combine(workRoot, options.WorkDirectoryName.Trim().Length == 0 ? "self-update" : options.WorkDirectoryName.Trim()); + var currentDir = Path.Combine(workRoot, options.CurrentDirectoryName.Trim().Length == 0 ? "app-current" : options.CurrentDirectoryName.Trim()); + var backupDir = Path.Combine(workRoot, options.BackupDirectoryName.Trim().Length == 0 ? "app-previous" : options.BackupDirectoryName.Trim()); + Directory.CreateDirectory(workspaceDir); + + var stageDir = Path.Combine(workspaceDir, $"stage-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"); + var packageFileName = $"pkg-{Guid.NewGuid():N}-{SanitizeFileName(check.AssetName)}"; + var packagePath = Path.Combine(workspaceDir, packageFileName); + + try + { + await DownloadFileAsync(check.AssetDownloadUrl, packagePath, options.DownloadTimeoutSeconds, cancellationToken); + + Directory.CreateDirectory(stageDir); + ZipFile.ExtractToDirectory(packagePath, stageDir, overwriteFiles: true); + + var entryDll = Path.Combine(stageDir, "TelegramPanel.Web.dll"); + if (!File.Exists(entryDll)) + return AppSelfUpdateApplyResult.Failed("更新包结构无效:缺少 TelegramPanel.Web.dll"); + + PromoteCurrentDirectory(stageDir, currentDir, backupDir); + stageDir = string.Empty; + + var restartDelaySeconds = options.RestartDelaySeconds; + if (restartDelaySeconds <= 0) restartDelaySeconds = 2; + _restartService.RequestRestart(TimeSpan.FromSeconds(restartDelaySeconds), $"self-update {check.LatestTag}"); + + _logger.LogWarning( + "Self update applied successfully. current={CurrentVersion} target={TargetTag} asset={AssetName}", + check.CurrentVersion, + check.LatestTag, + check.AssetName); + + return AppSelfUpdateApplyResult.Succeeded( + $"更新包已部署:{check.AssetName},服务将在约 {restartDelaySeconds} 秒后重启", + check.LatestTag, + check.LatestVersion); + } + finally + { + TryDeleteFile(packagePath); + if (!string.IsNullOrWhiteSpace(stageDir)) + TryDeleteDirectory(stageDir); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Apply self update failed"); + return AppSelfUpdateApplyResult.Failed($"更新失败:{ex.Message}"); + } + finally + { + _applyLock.Release(); + } + } + + private async Task FetchLatestReleaseAsync(string repo, CancellationToken cancellationToken) + { + var url = $"https://api.github.com/repos/{repo}/releases/latest"; + var client = CreateGitHubClient(); + + using var resp = await client.GetAsync(url, cancellationToken); + if (resp.StatusCode == HttpStatusCode.NotFound) + return null; + + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadAsStringAsync(cancellationToken); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + private async Task DownloadFileAsync( + string url, + string path, + int timeoutSeconds, + CancellationToken cancellationToken) + { + var timeout = timeoutSeconds <= 0 ? 300 : timeoutSeconds; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(timeout)); + + var client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Telegram-Panel"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/octet-stream"); + + using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token); + resp.EnsureSuccessStatusCode(); + + await using var source = await resp.Content.ReadAsStreamAsync(cts.Token); + await using var destination = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.SequentialScan); + await source.CopyToAsync(destination, cts.Token); + } + + private HttpClient CreateGitHubClient() + { + var client = _httpClientFactory.CreateClient(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Telegram-Panel"); + client.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + client.DefaultRequestHeaders.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28"); + return client; + } + + private string ResolveWorkRoot(SelfUpdateOptions options) + { + var configured = (options.WorkRootPath ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(configured)) + { + if (Path.IsPathRooted(configured)) + return configured; + + return Path.GetFullPath(Path.Combine(_environment.ContentRootPath, configured)); + } + + if (Directory.Exists("/data")) + return "/data"; + + return _environment.ContentRootPath; + } + + private static void PromoteCurrentDirectory(string stageDir, string currentDir, string backupDir) + { + if (Directory.Exists(backupDir)) + Directory.Delete(backupDir, recursive: true); + + var movedCurrent = false; + try + { + if (Directory.Exists(currentDir)) + { + Directory.Move(currentDir, backupDir); + movedCurrent = true; + } + + Directory.Move(stageDir, currentDir); + } + catch + { + if (movedCurrent && Directory.Exists(backupDir) && !Directory.Exists(currentDir)) + { + Directory.Move(backupDir, currentDir); + } + + throw; + } + } + + private static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // ignore + } + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // ignore + } + } + + private static string SanitizeFileName(string fileName) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var buffer = fileName.ToCharArray(); + for (var i = 0; i < buffer.Length; i++) + { + if (invalidChars.Contains(buffer[i])) + buffer[i] = '_'; + } + + return new string(buffer); + } + + private static string? NormalizeNotes(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + return null; + + var notes = body.Trim(); + if (notes.Length > 2000) + notes = notes[..2000].Trim() + "\n…"; + + return notes; + } + + private static GitHubAssetDto? PickAsset(IReadOnlyList? assets, string assetKeyword) + { + if (assets == null || assets.Count == 0) + return null; + + return assets + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .Where(x => x.Name!.Contains(assetKeyword, StringComparison.OrdinalIgnoreCase)) + .Where(x => x.Name!.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + .Where(x => !string.IsNullOrWhiteSpace(x.BrowserDownloadUrl)) + .OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(); + } + + private static string? ResolveBlockedReason(bool updateAvailable, bool assetFound, bool isDocker, bool dockerOnly, string archLabel) + { + if (!updateAvailable) + return "当前已是最新版本"; + if (!assetFound) + return $"未找到匹配架构 {archLabel} 的更新包"; + if (dockerOnly && !isDocker) + return "当前不是 Docker 运行环境"; + return null; + } + + private static bool TryGetAssetKeyword(Architecture architecture, out string keyword, out string archLabel) + { + switch (architecture) + { + case Architecture.X64: + keyword = "linux-x64"; + archLabel = "linux-x64"; + return true; + case Architecture.Arm64: + keyword = "linux-arm64"; + archLabel = "linux-arm64"; + return true; + default: + keyword = string.Empty; + archLabel = architecture.ToString(); + return false; + } + } + + private static bool IsRunningInDocker() + { + var dotnetInContainer = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"); + if (string.Equals(dotnetInContainer, "true", StringComparison.OrdinalIgnoreCase)) + return true; + + return File.Exists("/.dockerenv"); + } + + private static SimpleVersion? ParseVersionFromTag(string? tagName) + { + if (string.IsNullOrWhiteSpace(tagName)) + return null; + + var s = tagName.Trim(); + if (s.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + s = s[1..]; + + var dash = s.IndexOf('-'); + if (dash > 0) s = s[..dash]; + + var plus = s.IndexOf('+'); + if (plus > 0) s = s[..plus]; + + var parts = s.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length < 3) + return null; + + if (!int.TryParse(parts[0], out var major)) return null; + if (!int.TryParse(parts[1], out var minor)) return null; + if (!int.TryParse(parts[2], out var patch)) return null; + return new SimpleVersion(major, minor, patch); + } + + private static int CompareVersions(SimpleVersion latest, string current) + { + var cur = ParseVersionFromTag(current) ?? new SimpleVersion(0, 0, 0); + return latest.CompareTo(cur); + } + + private readonly record struct SimpleVersion(int Major, int Minor, int Patch) : IComparable + { + public int CompareTo(SimpleVersion other) + { + var r = Major.CompareTo(other.Major); + if (r != 0) return r; + r = Minor.CompareTo(other.Minor); + if (r != 0) return r; + return Patch.CompareTo(other.Patch); + } + + public override string ToString() => $"{Major}.{Minor}.{Patch}"; + } + + private sealed class GitHubReleaseDto + { + [JsonPropertyName("tag_name")] + public string? TagName { get; set; } + + [JsonPropertyName("html_url")] + public string? HtmlUrl { get; set; } + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; set; } + + [JsonPropertyName("body")] + public string? Body { get; set; } + + [JsonPropertyName("assets")] + public List? Assets { get; set; } + } + + private sealed class GitHubAssetDto + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("browser_download_url")] + public string? BrowserDownloadUrl { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + } +} + +public sealed class AppSelfUpdateInfo +{ + public bool Success { get; init; } + public string? Error { get; init; } + public bool Enabled { get; init; } + + public string CurrentVersion { get; init; } = "Unknown"; + public string? LatestVersion { get; init; } + public string? LatestTag { get; init; } + public bool UpdateAvailable { get; init; } + public string? Url { get; init; } + public DateTimeOffset? PublishedAt { get; init; } + public string? Notes { get; init; } + public DateTimeOffset CheckedAtUtc { get; init; } = DateTimeOffset.UtcNow; + + public bool IsDocker { get; init; } + public bool CanApply { get; init; } + public string? BlockedReason { get; init; } + + public string? AssetName { get; init; } + public long? AssetSizeBytes { get; init; } + public string? AssetDownloadUrl { get; init; } + + public static AppSelfUpdateInfo Disabled(string currentVersion, bool isDocker, string? reason = null) => new() + { + Success = true, + Enabled = false, + CurrentVersion = currentVersion, + IsDocker = isDocker, + UpdateAvailable = false, + CanApply = false, + BlockedReason = reason, + CheckedAtUtc = DateTimeOffset.UtcNow + }; + + public static AppSelfUpdateInfo Failed(string currentVersion, bool isDocker, string error) => new() + { + Success = false, + Enabled = true, + Error = error, + CurrentVersion = currentVersion, + IsDocker = isDocker, + UpdateAvailable = false, + CanApply = false, + CheckedAtUtc = DateTimeOffset.UtcNow + }; +} + +public sealed class AppSelfUpdateApplyResult +{ + public bool Success { get; init; } + public string Message { get; init; } = string.Empty; + public bool RestartScheduled { get; init; } + public string? LatestTag { get; init; } + public string? LatestVersion { get; init; } + + public static AppSelfUpdateApplyResult Failed(string message) => new() + { + Success = false, + Message = message, + RestartScheduled = false + }; + + public static AppSelfUpdateApplyResult Succeeded(string message, string? latestTag, string? latestVersion) => new() + { + Success = true, + Message = message, + RestartScheduled = true, + LatestTag = latestTag, + LatestVersion = latestVersion + }; +} diff --git a/src/TelegramPanel.Web/Services/SelfUpdateOptions.cs b/src/TelegramPanel.Web/Services/SelfUpdateOptions.cs new file mode 100644 index 0000000..a447d82 --- /dev/null +++ b/src/TelegramPanel.Web/Services/SelfUpdateOptions.cs @@ -0,0 +1,44 @@ +namespace TelegramPanel.Web.Services; + +public sealed class SelfUpdateOptions +{ + /// + /// 是否启用应用内一键更新 + /// + public bool Enabled { get; set; } = true; + + /// + /// 仅允许在 Docker 容器内执行更新 + /// + public bool DockerOnly { get; set; } = true; + + /// + /// 更新工作根目录。为空时优先使用 /data,不存在则回退到 ContentRoot。 + /// + public string WorkRootPath { get; set; } = ""; + + /// + /// 更新包下载与解压的临时目录名(位于 WorkRootPath 下) + /// + public string WorkDirectoryName { get; set; } = "self-update"; + + /// + /// 当前版本目录名(位于 WorkRootPath 下) + /// + public string CurrentDirectoryName { get; set; } = "app-current"; + + /// + /// 旧版本备份目录名(位于 WorkRootPath 下) + /// + public string BackupDirectoryName { get; set; } = "app-previous"; + + /// + /// 下载更新包超时秒数 + /// + public int DownloadTimeoutSeconds { get; set; } = 300; + + /// + /// 更新完成后,触发应用重启的延迟秒数 + /// + public int RestartDelaySeconds { get; set; } = 2; +} diff --git a/src/TelegramPanel.Web/Services/UpdateCheckService.cs b/src/TelegramPanel.Web/Services/UpdateCheckService.cs index 1eed80a..d036139 100644 --- a/src/TelegramPanel.Web/Services/UpdateCheckService.cs +++ b/src/TelegramPanel.Web/Services/UpdateCheckService.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; @@ -199,9 +200,16 @@ public sealed class UpdateCheckService private sealed class GitHubReleaseDto { + [JsonPropertyName("tag_name")] public string? TagName { get; set; } + + [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } + + [JsonPropertyName("published_at")] public DateTimeOffset? PublishedAt { get; set; } + + [JsonPropertyName("body")] public string? Body { get; set; } } From bd454decf6521394d96e57118357ddd33296b08a Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 02:38:29 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=EF=BB=BFdocs(docker):=20=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E6=96=87=E6=A1=A3=E5=B9=B6=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E4=B8=BA=20GHCR=20=E9=95=9C=E5=83=8F=E4=BC=98=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次变更: - README 删除冗长 CI/Release 说明,保留稳定版/开发版的 docker pull + docker run 直接命令。 - README 的 Docker 一键部署改为 compose 拉取远程镜像流程(稳定版默认、开发版切换 TP_IMAGE)。 - docker-compose.yml 默认使用 ghcr.io/moeacgx/telegram-panel:latest,并设置 pull_policy=always,优先远程镜像部署。 - .env.example 新增 TP_IMAGE 配置说明,默认 latest,可一行切换 dev-latest。 --- .env.example | 11 +++++-- README.md | 78 +++++++++++++++++----------------------------- docker-compose.yml | 5 ++- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/.env.example b/.env.example index 9355180..32da1c4 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,17 @@ # 2) 按需填写你的值 # # Compose 会自动读取当前目录下的 .env 用于变量替换: -# docker compose up -d --build +# docker compose pull +# docker compose up -d # # 也可以显式指定: -# docker compose --env-file .env up -d --build +# docker compose --env-file .env pull +# docker compose --env-file .env up -d + +# 镜像标签(默认稳定版 latest) +# 开发版可改为: +# TP_IMAGE=ghcr.io/moeacgx/telegram-panel:dev-latest +TP_IMAGE=ghcr.io/moeacgx/telegram-panel:latest # Webhook 模式(生产环境可选) # - true: 使用 Webhook(需要公网 HTTPS + 反向代理转发 /api/bot/webhook/*) diff --git a/README.md b/README.md index 2918233..628ebf2 100644 --- a/README.md +++ b/README.md @@ -55,18 +55,36 @@ ### Docker 一键部署(推荐) -🐳 面向小白:`git clone` → `docker compose up` → 浏览器打开 → 登录改密码 → 配置 ApiId/ApiHash。 +```bash +git clone https://github.com/moeacgx/Telegram-Panel +cd Telegram-Panel +cp .env.example .env +``` ### 环境要求 Docker(Windows 推荐 Docker Desktop + WSL2;Linux 直接装 Docker Engine) -### 启动 +### 稳定版(默认) ```bash -git clone https://github.com/moeacgx/Telegram-Panel -cd Telegram-Panel -docker compose up -d --build +docker compose pull +docker compose up -d +``` + +### 开发版 + +先把 `.env` 里的 `TP_IMAGE` 改成: + +```bash +TP_IMAGE=ghcr.io/moeacgx/telegram-panel:dev-latest +``` + +然后执行: + +```bash +docker compose pull +docker compose up -d ``` 启动后访问:`http://localhost:5000` @@ -78,56 +96,16 @@ docker compose up -d --build 登录后到「修改密码」页面改掉即可。 -> 更完整的安装、更新、导入与生产部署建议:见 https://moeacgx.github.io/Telegram-Panel/ 。 - -## CI 自动构建镜像(GitHub Actions) - -已内置工作流:`.github/workflows/docker.yml` - -触发规则: -- 推送到 `main` / `dev`:自动构建并推送镜像 -- 推送 tag(如 `v1.2.3`):自动构建并推送版本镜像 -- PR 到 `main` / `dev`:只构建验证,不推送 - -镜像仓库(GHCR): -- `ghcr.io/<你的GitHub用户名或组织>/telegram-panel` - -标签规则: -- `main` 分支:`latest`、`main`、`sha-xxxxxxx` -- `dev` 分支:`dev`、`dev-latest`、`sha-xxxxxxx` -- `v*` tag:例如 `v1.2.3` - -拉取示例: +## Docker 镜像(只看命令) ```bash -# 稳定版 +# 稳定版:拉取 + 运行 docker pull ghcr.io/moeacgx/telegram-panel:latest +docker run -d --name telegram-panel --restart unless-stopped -p 5000:5000 -v ./docker-data:/data ghcr.io/moeacgx/telegram-panel:latest -# 开发版 +# 开发版:拉取 + 运行 docker pull ghcr.io/moeacgx/telegram-panel:dev-latest - -# 指定版本 -docker pull ghcr.io/moeacgx/telegram-panel:v1.2.3 -``` - -## 自动发布与 Changelog - -已内置工作流:`.github/workflows/release.yml` - -触发规则: -- 推送 `v*` tag(如 `v1.2.3`)后,自动创建 GitHub Release -- Release 内容自动生成 changelog(基于提交/PR),并读取 `.github/release.yml` 分类规则 -- 同时自动打包并上传 Linux 更新包资产: - - `telegram-panel-vX.Y.Z-linux-x64.zip` - - `telegram-panel-vX.Y.Z-linux-arm64.zip` - -发布示例: - -```bash -git checkout main -git pull --ff-only origin main -git tag v1.2.3 -git push origin v1.2.3 +docker run -d --name telegram-panel --restart unless-stopped -p 5000:5000 -v ./docker-data:/data ghcr.io/moeacgx/telegram-panel:dev-latest ``` ## Docker 一键更新(面板内) diff --git a/docker-compose.yml b/docker-compose.yml index ec2570e..9db4c8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,7 @@ services: telegram-panel: - build: - context: . - dockerfile: ${TP_DOCKERFILE:-Dockerfile} + image: ${TP_IMAGE:-ghcr.io/moeacgx/telegram-panel:latest} + pull_policy: always container_name: telegram-panel restart: unless-stopped ports: From 36faff31e1241ae172451d6fdc95a2aa6c363eb4 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 02:42:44 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=EF=BB=BFdocs(readme):=20=E9=87=8D=E6=8E=92?= =?UTF-8?q?=20Docker=20=E9=83=A8=E7=BD=B2=E8=AF=B4=E6=98=8E=EF=BC=8C?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=BA=20compose=20=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次文档调整: - 将「Docker 一键部署」改为 3 步结构(准备项目、选择镜像版本、启动),降低阅读负担。 - 删除重复的「Docker 镜像(只看命令)」docker run 段,避免与 compose 方案混淆。 - 保留稳定版/开发版切换方式(TP_IMAGE)并补充常用 compose 命令(日志、更新、重启、停止)。 - 保持「Docker 一键更新(面板内)」说明独立,部署与更新职责分离更清晰。 --- README.md | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 628ebf2..aa8bfe4 100644 --- a/README.md +++ b/README.md @@ -55,57 +55,59 @@ ### Docker 一键部署(推荐) +环境要求:Docker(Windows 推荐 Docker Desktop + WSL2;Linux 直接装 Docker Engine) + +#### 第一步:准备项目 + ```bash git clone https://github.com/moeacgx/Telegram-Panel cd Telegram-Panel cp .env.example .env ``` -### 环境要求 +#### 第二步:选择镜像版本 -Docker(Windows 推荐 Docker Desktop + WSL2;Linux 直接装 Docker Engine) - -### 稳定版(默认) +默认是稳定版(无需改动): ```bash -docker compose pull -docker compose up -d +TP_IMAGE=ghcr.io/moeacgx/telegram-panel:latest ``` -### 开发版 - -先把 `.env` 里的 `TP_IMAGE` 改成: +如果你要开发版,改 `.env` 为: ```bash TP_IMAGE=ghcr.io/moeacgx/telegram-panel:dev-latest ``` -然后执行: +#### 第三步:启动 ```bash docker compose pull docker compose up -d ``` -启动后访问:`http://localhost:5000` +访问:`http://localhost:5000` -### 默认后台账号(首次登录) +#### 默认后台账号(首次登录) 用户名:`admin` 密码:`admin123` 登录后到「修改密码」页面改掉即可。 -## Docker 镜像(只看命令) +#### 常用命令 ```bash -# 稳定版:拉取 + 运行 -docker pull ghcr.io/moeacgx/telegram-panel:latest -docker run -d --name telegram-panel --restart unless-stopped -p 5000:5000 -v ./docker-data:/data ghcr.io/moeacgx/telegram-panel:latest +# 查看日志 +docker compose logs -f -# 开发版:拉取 + 运行 -docker pull ghcr.io/moeacgx/telegram-panel:dev-latest -docker run -d --name telegram-panel --restart unless-stopped -p 5000:5000 -v ./docker-data:/data ghcr.io/moeacgx/telegram-panel:dev-latest +# 更新到当前 .env 指定的镜像版本 +docker compose pull +docker compose up -d + +# 重启 / 停止 +docker compose restart +docker compose down ``` ## Docker 一键更新(面板内) From dbc1fe7155e966964559fc2a3ac7419eac8b5a3d Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 03:04:21 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=EF=BB=BFfeat(update-ui):=20=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=9B=B4=E6=96=B0=E5=85=A5=E5=8F=A3=E5=88=B0=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=E7=89=88=E6=9C=AC=E5=BC=B9=E7=AA=97=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E8=87=AA=E5=8A=A8=E6=8F=90=E9=86=92=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次版本主要调整: - 更新入口统一:移除系统设置页中的“应用更新(Docker)”卡片,避免与左上角版本弹窗重复和状态冲突。 - 自动检测提醒:主布局加载后会自动检查更新,检测到新版本时自动弹出版本弹窗,提示是否执行一键更新。 - 版本弹窗增强:新增“一键更新并重启”按钮,支持确认后直接调用自更新流程。 - 发布信息可视化:在版本弹窗中展示 Latest Release Notes,方便直接查看最新发布说明。 - 状态一致性改进:顶部新版本角标与弹窗数据统一改为使用 AppSelfUpdateService,检查结果与可更新状态保持一致。 --- .../Dialogs/VersionInfoDialog.razor | 132 +++++++++++-- .../Components/Layout/MainLayout.razor | 31 ++- .../Components/Pages/Settings.razor | 185 ------------------ 3 files changed, 139 insertions(+), 209 deletions(-) diff --git a/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor index f889872..cf57097 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/VersionInfoDialog.razor @@ -1,6 +1,8 @@ @namespace TelegramPanel.Web.Components.Dialogs @using TelegramPanel.Web.Services -@inject UpdateCheckService UpdateCheck +@inject AppSelfUpdateService SelfUpdate +@inject ISnackbar Snackbar +@inject IDialogService DialogService @@ -9,6 +11,9 @@ 版本:@VersionService.Version + + 运行环境:@(_updateInfo?.IsDocker == true ? "Docker" : "非 Docker") + @@ -19,15 +24,34 @@ 检查更新 - - @if (_checking) - { - - } - 立即检查 - + + + @if (_checking) + { + + } + 立即检查 + + + @if (_updating) + { + + } + 一键更新并重启 + + + @if (AutoPrompt && _updateInfo is { Success: true, UpdateAvailable: true }) + { + + 检测到新版本,是否现在执行一键更新? + + } + @if (_updateInfo == null) { 正在检查… @@ -38,9 +62,9 @@ 检查失败:@_updateInfo.Error } - else if (_updateInfo.Source == "disabled") + else if (!_updateInfo.Enabled) { - 已禁用检查更新 + 已禁用自动更新 } else if (_updateInfo.UpdateAvailable) { @@ -54,12 +78,18 @@ } - @if (!string.IsNullOrWhiteSpace(_updateInfo.Notes)) + @if (!string.IsNullOrWhiteSpace(_updateInfo.AssetName)) { - 更新说明(节选) - - @_updateInfo.Notes - + + 匹配更新包:@_updateInfo.AssetName + + } + + @if (!string.IsNullOrWhiteSpace(_updateInfo.BlockedReason)) + { + + 无法一键更新:@_updateInfo.BlockedReason + } } else @@ -69,10 +99,18 @@ } + @if (!string.IsNullOrWhiteSpace(_updateInfo?.Notes)) + { + Latest Release Notes + + @_updateInfo.Notes + + } + @if (_updateInfo != null) { - 最近检查:@_updateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")(来源:@_updateInfo.Source) + 最近检查:@_updateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") } @@ -85,13 +123,24 @@ @code { [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public bool AutoPrompt { get; set; } private bool _checking; - private UpdateCheckInfo? _updateInfo; + private bool _updating; + private AppSelfUpdateInfo? _updateInfo; + + private bool CanApply => + _updateInfo is + { + Success: true, + Enabled: true, + UpdateAvailable: true, + CanApply: true + }; protected override async Task OnInitializedAsync() { - _updateInfo = await UpdateCheck.GetLatestAsync(forceRefresh: false); + _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: false); } private async Task ForceRefresh() @@ -99,7 +148,7 @@ _checking = true; try { - _updateInfo = await UpdateCheck.GetLatestAsync(forceRefresh: true); + _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true); } finally { @@ -107,6 +156,51 @@ } } + private async Task ApplyUpdate() + { + if (_updating) + return; + + if (_updateInfo == null || !CanApply) + { + Snackbar.Add(_updateInfo?.BlockedReason ?? "当前条件不满足一键更新", Severity.Warning); + return; + } + + var target = _updateInfo.LatestTag ?? _updateInfo.LatestVersion ?? "最新版本"; + bool? ok = await DialogService.ShowMessageBox( + "确认一键更新", + $"将升级到 {target},并在部署后自动重启服务。是否继续?", + yesText: "继续", + cancelText: "取消"); + + if (ok != true) + return; + + _updating = true; + try + { + var result = await SelfUpdate.ApplyLatestAsync(); + if (result.Success) + { + Snackbar.Add(result.Message, Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + return; + } + + Snackbar.Add(result.Message, Severity.Error); + _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true); + } + catch (Exception ex) + { + Snackbar.Add($"执行更新异常:{ex.Message}", Severity.Error); + } + finally + { + _updating = false; + } + } + private void Close() { MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Layout/MainLayout.razor b/src/TelegramPanel.Web/Components/Layout/MainLayout.razor index d57eb10..13c85ae 100644 --- a/src/TelegramPanel.Web/Components/Layout/MainLayout.razor +++ b/src/TelegramPanel.Web/Components/Layout/MainLayout.razor @@ -50,12 +50,13 @@ @using TelegramPanel.Web.Services @inject UiPreferencesService UiPreferences @inject IDialogService DialogService -@inject UpdateCheckService UpdateCheck +@inject AppSelfUpdateService SelfUpdate @code { private bool _drawerOpen = true; private bool _isDarkMode = true; - private UpdateCheckInfo? _updateInfo; + private AppSelfUpdateInfo? _updateInfo; + private bool _autoPromptShown; private MudTheme _theme = new() { @@ -99,6 +100,11 @@ } private async Task ShowVersionInfo() + { + await ShowVersionInfo(autoPrompt: false); + } + + private async Task ShowVersionInfo(bool autoPrompt) { var options = new DialogOptions { @@ -107,16 +113,31 @@ FullWidth = true }; - DialogService.Show("版本信息", new DialogParameters(), options); - await Task.CompletedTask; + var parameters = new DialogParameters + { + [nameof(TelegramPanel.Web.Components.Dialogs.VersionInfoDialog.AutoPrompt)] = autoPrompt + }; + + var dialog = DialogService.Show("版本信息", parameters, options); + await dialog.Result; + + // 弹窗可能已触发更新/重启,关闭后刷新一次顶部状态 + _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true); + await InvokeAsync(StateHasChanged); } private async Task LoadUpdateInfoAsync() { try { - _updateInfo = await UpdateCheck.GetLatestAsync(forceRefresh: false); + _updateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: false); await InvokeAsync(StateHasChanged); + + if (_updateInfo is { Success: true, UpdateAvailable: true } && !_autoPromptShown) + { + _autoPromptShown = true; + await InvokeAsync(async () => await ShowVersionInfo(autoPrompt: true)); + } } catch { diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 80ec082..2aa8400 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -13,7 +13,6 @@ @inject PanelTimeZoneService TimeZone @inject NavigationManager Navigation @inject CloudMailClient CloudMail -@inject AppSelfUpdateService SelfUpdate @using TelegramPanel.Web.Services @using TelegramPanel.Core.Utils @@ -201,98 +200,6 @@ - - - 应用更新(Docker) - - - - 一键更新会下载 GitHub Release 的 Linux 更新包,写入 /data/app-current,并自动重启服务。 - - - 当前版本:v@VersionService.Version - - - 运行环境:@(SelfUpdate.IsDockerEnvironment ? "Docker" : "非 Docker") - - - @if (selfUpdateInfo != null) - { - - - @if (!selfUpdateInfo.Success) - { - - 检查失败:@selfUpdateInfo.Error - - } - else if (!selfUpdateInfo.Enabled) - { - - 自动更新已禁用 - - } - else if (selfUpdateInfo.UpdateAvailable) - { - - 发现新版本:v@selfUpdateInfo.LatestVersion(当前 v@selfUpdateInfo.CurrentVersion) - - - 更新包:@(selfUpdateInfo.AssetName ?? "未匹配") - - @if (!string.IsNullOrWhiteSpace(selfUpdateInfo.BlockedReason)) - { - - @selfUpdateInfo.BlockedReason - - } - } - else - { - - 当前已是最新版本 - - } - - @if (!string.IsNullOrWhiteSpace(selfUpdateInfo.Url)) - { - - 查看发布页 - - } - - - 最近检查:@selfUpdateInfo.CheckedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") - - } - - - - @if (selfUpdateChecking) - { - - } - 检查更新 - - - @if (selfUpdateApplying) - { - - } - 一键更新并重启 - - - - 日志设置 @@ -365,9 +272,6 @@ private string cloudMailAdminEmail = ""; private string cloudMailAdminPassword = ""; private bool cloudMailGeneratingToken = false; - private bool selfUpdateChecking = false; - private bool selfUpdateApplying = false; - private AppSelfUpdateInfo? selfUpdateInfo; private sealed record TimeZoneChoice(string Id, string Label); @@ -390,15 +294,6 @@ } } - private bool CanApplySelfUpdate => - selfUpdateInfo is - { - Success: true, - Enabled: true, - UpdateAvailable: true, - CanApply: true - }; - private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, Environment); private bool LocalConfigExists => System.IO.File.Exists(LocalConfigPath); @@ -755,86 +650,6 @@ } } - private async Task CheckSelfUpdate() - { - if (selfUpdateChecking) - return; - - selfUpdateChecking = true; - try - { - selfUpdateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true, CancellationToken.None); - if (!selfUpdateInfo.Success) - { - Snackbar.Add($"检查更新失败:{selfUpdateInfo.Error}", Severity.Warning); - return; - } - - if (!selfUpdateInfo.Enabled) - { - Snackbar.Add("自动更新功能当前已禁用", Severity.Info); - return; - } - - if (selfUpdateInfo.UpdateAvailable) - Snackbar.Add($"发现新版本:v{selfUpdateInfo.LatestVersion}", Severity.Info); - else - Snackbar.Add("当前已是最新版本", Severity.Success); - } - catch (Exception ex) - { - Snackbar.Add($"检查更新异常:{ex.Message}", Severity.Error); - } - finally - { - selfUpdateChecking = false; - } - } - - private async Task ApplySelfUpdate() - { - if (selfUpdateApplying) - return; - - if (selfUpdateInfo == null || !CanApplySelfUpdate) - { - Snackbar.Add(selfUpdateInfo?.BlockedReason ?? "请先检查更新并确认可更新", Severity.Warning); - return; - } - - var target = selfUpdateInfo.LatestTag ?? selfUpdateInfo.LatestVersion ?? "最新版本"; - bool? ok = await DialogService.ShowMessageBox( - "确认一键更新", - $"将升级到 {target},并在部署完成后自动重启服务。是否继续?", - yesText: "继续", - cancelText: "取消"); - - if (ok != true) - return; - - selfUpdateApplying = true; - try - { - var result = await SelfUpdate.ApplyLatestAsync(CancellationToken.None); - if (result.Success) - { - Snackbar.Add(result.Message, Severity.Success); - return; - } - - Snackbar.Add(result.Message, Severity.Error); - selfUpdateInfo = await SelfUpdate.CheckLatestAsync(forceRefresh: true, CancellationToken.None); - } - catch (Exception ex) - { - Snackbar.Add($"执行更新异常:{ex.Message}", Severity.Error); - } - finally - { - selfUpdateApplying = false; - } - } - private async Task ClearCache() { bool? result = await DialogService.ShowMessageBox( From e9951748a61780abd82f58ed011e7880f5a58e4f Mon Sep 17 00:00:00 2001 From: meoacgx Date: Tue, 3 Mar 2026 03:19:07 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=EF=BB=BFrelease(dev):=20=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E8=87=B3=201.31.0=20=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20v1.31=20=E6=9B=B4=E6=96=B0=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次版本主要更新: - 版本号统一升级到 1.31.0(Version/AssemblyVersion/FileVersion/InformationalVersion)。 - README 增加 v1.31 更新清单,明确覆盖 TData 导入与导出链路优化。 - README 同步补充 Bot 频道异常治理与 Docker 一键更新能力说明。 - README 更新“更新入口”描述为左上角版本弹窗,避免与旧文案冲突。 - README 补充 CI/CD 与部署文档重排后的核心变化点,便于发布阅读。 --- Directory.Build.props | 8 ++++---- README.md | 17 +++++++++-------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 85792e6..ec8f0a7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,10 +5,10 @@ - 1.30.0 - 1.30.0.0 - 1.30.0.0 - 1.30.0 + 1.31.0 + 1.31.0.0 + 1.31.0.0 + 1.31.0 Telegram Panel Contributors diff --git a/README.md b/README.md index aa8bfe4..86aecb7 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,17 @@ 👥 站长交流群

-## v1.30 重要更新 +## v1.31 重要更新 本次版本主要更新: -- ✨ 废号判定补全:`AUTH_KEY_DUPLICATED`(Session 冲突)与 `SESSION_REVOKED`(Session 已撤销)计入废号(影响“只看废号”筛选与清理逻辑) -- ✨ 一键清理筛选废号:账号页在勾选“只看废号”后提供“清理废号(筛选)”,按当前筛选结果直接批量清理 -- ⚡ 自动同步调度优化:记录上次自动同步时间,避免重启即跑一轮导致限流 -- ⚡ 批量任务配置落地:支持保存默认间隔/最大并发/重试开关,并默认更保守(降低风控风险) -- 🐛 日志降噪与限流降速:减少刷屏日志与高频请求 -- 📚 新增文档站:更易维护与检索(https://moeacgx.github.io/Telegram-Panel/) +- ✨ TData 导入完善:支持完整导入 tdata 压缩包,并补全导入链路会话校验,减少导入后不可用情况 +- ✨ TData 导出优化:支持 Telethon/TData 双格式导出;修复官方客户端登录兼容问题;优先已授权 DCSession;补齐 `user_id` 写入;修复旧包缓存导致重复下载 +- ✨ Bot 频道异常治理:支持删除失效频道、频道状态检测(正常/异常)和异常筛选,降低被踢/封禁后脏数据残留 +- ✨ Docker 一键更新:支持面板内检测 Release 并一键更新,自动下载匹配架构包、部署到 `/data/app-current` 并重启生效 +- ⚡ 更新入口统一:左上角版本弹窗集成自动检测、更新确认与 Latest Release Notes 展示(移除设置页重复入口) +- ⚡ CI/CD 完整化:新增 Docker 自动构建推送(GHCR)与 Release 自动发布/自动 changelog/更新包资产上传 +- 📚 部署文档重排:Docker 部署流程精简为 compose 单路径(稳定版/开发版切换更直观) ## 功能概览 @@ -112,7 +113,7 @@ docker compose down ## Docker 一键更新(面板内) -面板已支持在 Docker 部署场景下一键更新(`系统设置 -> 应用更新(Docker)`): +面板已支持在 Docker 部署场景下一键更新(左上角版本号 -> 版本信息弹窗): 1. 点击“检查更新”,读取 GitHub 最新 Release。 2. 点击“一键更新并重启”,自动下载对应架构的 Linux 更新包到 `/data/app-current`。