From bb138b022bb2d56b924fd59dfc81c766aae2e80a Mon Sep 17 00:00:00 2001 From: Ligreus Date: Mon, 19 Oct 2026 18:27:54 +0500 Subject: [PATCH 1/6] Start the bundled server from the client When a Server folder sits next to BiStudent.exe, the client starts the server itself without a console window and ties it to a job object, so the server stops when the client exits or crashes. The login form hides the server address and waits for the server on sign-in. Only one client instance runs at a time, since the server lives with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKfFePzqcEUGQQ4JyA4NQn --- .gitattributes | 1 - BiStudent/LocalServer.cs | 176 +++++++++++++++++++++++++++++++++++++++ BiStudent/LoginForm.cs | 28 +++++-- BiStudent/Program.cs | 15 ++++ installer/BiStudent.cmd | 24 ------ installer/BiStudent.iss | 52 ------------ 6 files changed, 214 insertions(+), 82 deletions(-) delete mode 100644 .gitattributes create mode 100644 BiStudent/LocalServer.cs delete mode 100644 installer/BiStudent.cmd delete mode 100644 installer/BiStudent.iss diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index db7e1d2..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.cmd text eol=crlf diff --git a/BiStudent/LocalServer.cs b/BiStudent/LocalServer.cs new file mode 100644 index 0000000..9ec11c4 --- /dev/null +++ b/BiStudent/LocalServer.cs @@ -0,0 +1,176 @@ +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; + +namespace BiStudent; + +internal static class LocalServer +{ + public const string Url = "http://localhost:5080"; + + private static readonly HttpClient http = new() { Timeout = Timeout.InfiniteTimeSpan }; + private static readonly object sync = new(); + private static Task? launchTask; + private static Process? process; + private static IntPtr jobHandle; + private static bool launchFailed; + + public static string? ExecutablePath + { + get + { + var path = Path.Combine(AppContext.BaseDirectory, "Server", "BiStudent.Server.exe"); + return File.Exists(path) ? path : null; + } + } + + public static bool IsBundled => ExecutablePath is not null; + + public static void Start() + { + if (ExecutablePath is not { } executable) return; + lock (sync) + launchTask ??= Task.Run(() => LaunchAsync(executable)); + } + + public static async Task WaitUntilReadyAsync(TimeSpan timeout, CancellationToken cancellationToken = default) + { + if (!IsBundled) return true; + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + try + { + var task = launchTask; + if (task is not null) await task.WaitAsync(deadline.Token); + while (!deadline.IsCancellationRequested) + { + if (launchFailed || process is { HasExited: true }) return false; + if (await IsHealthyAsync(TimeSpan.FromSeconds(2), deadline.Token)) return true; + if (launchFailed || process is { HasExited: true }) return false; + await Task.Delay(400, deadline.Token); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { } + return false; + } + + private static async Task LaunchAsync(string executable) + { + if (await IsHealthyAsync(TimeSpan.FromSeconds(1), CancellationToken.None)) return; + Process? started = null; + try + { + started = new Process + { + StartInfo = new ProcessStartInfo(executable) + { + Arguments = "--urls http://0.0.0.0:5080", + WorkingDirectory = Path.GetDirectoryName(executable)!, + UseShellExecute = false, + CreateNoWindow = true + } + }; + started.StartInfo.Environment["Demo__Enabled"] = "true"; + if (!started.Start() || !AttachToJob(started)) + throw new InvalidOperationException("Не удалось запустить сервер BiStudent."); + process = started; + } + catch + { + launchFailed = true; + if (started is not null) + { + try { if (!started.HasExited) started.Kill(entireProcessTree: true); } + catch { } + started.Dispose(); + } + } + } + + private static async Task IsHealthyAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeout.CancelAfter(timeout); + try + { + using var response = await http.GetAsync($"{Url}/health", HttpCompletionOption.ResponseHeadersRead, requestTimeout.Token); + return response.StatusCode == HttpStatusCode.OK; + } + catch (HttpRequestException) { return false; } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return false; } + } + + private static bool AttachToJob(Process started) + { + if (jobHandle != IntPtr.Zero) return true; + var job = IntPtr.Zero; + try + { + job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) return false; + var limits = new JobObjectExtendedLimitInformation + { + BasicLimitInformation = new JobObjectBasicLimitInformation { LimitFlags = 0x2000 } + }; + if (!SetInformationJobObject(job, 9, ref limits, (uint)Marshal.SizeOf()) + || !AssignProcessToJobObject(job, started.Handle)) return false; + jobHandle = job; + job = IntPtr.Zero; + return true; + } + catch { return false; } + finally { if (job != IntPtr.Zero) CloseHandle(job); } + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectBasicLimitInformation + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct JobObjectExtendedLimitInformation + { + public JobObjectBasicLimitInformation BasicLimitInformation; + public IoCounters IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr CreateJobObject(IntPtr securityAttributes, string? name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(IntPtr job, int informationClass, + ref JobObjectExtendedLimitInformation information, uint length); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); +} diff --git a/BiStudent/LoginForm.cs b/BiStudent/LoginForm.cs index 6474d8f..10477d4 100644 --- a/BiStudent/LoginForm.cs +++ b/BiStudent/LoginForm.cs @@ -21,7 +21,7 @@ public LoginForm(string? initialError = null) StartPosition = FormStartPosition.CenterScreen; AutoScaleMode = AutoScaleMode.Dpi; BackColor = Theme.Background; ForeColor = Theme.Text; Font = Theme.Font(10); Build(); - server.Text = settings.ServerUrl; + server.Text = LocalServer.IsBundled ? LocalServer.Url : settings.ServerUrl; login.Text = settings.LastLogin; remember.Checked = settings.RememberMe; error.Text = initialError ?? ""; @@ -36,14 +36,18 @@ private void Build() layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 54)); layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 46)); var left = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(36, 28, 16, 28), Padding = new Padding(28) }; var form = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 11, BackColor = Theme.Surface }; - foreach (var height in new[] { 48, 45, 22, 38, 22, 38, 22, 38, 36, 50, 44 }) form.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + var bundled = LocalServer.IsBundled; + foreach (var height in new[] { 48, 45, bundled ? 0 : 22, bundled ? 0 : 38, 22, 38, 22, 38, 36, 50, 44 }) form.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); var logo = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, WrapContents = false }; var mark = new Label { Text = "✓", Width = 42, Height = 42, BackColor = Theme.Accent, ForeColor = Theme.Text, Font = Theme.Font(20, FontStyle.Bold), TextAlign = ContentAlignment.MiddleCenter }; var brand = new Label { Text = "BiStudent", Width = 240, Height = 42, ForeColor = Theme.Text, Font = Theme.Font(19, FontStyle.Bold), TextAlign = ContentAlignment.MiddleLeft }; logo.Controls.Add(mark); logo.Controls.Add(brand); form.Controls.Add(logo, 0, 0); form.Controls.Add(Ui.Label("Кабинет преподавателя", 13, Theme.Text, FontStyle.Bold), 0, 1); - form.Controls.Add(Ui.Label("Адрес сервера", 9, Theme.Muted), 0, 2); - server.Dock = DockStyle.Fill; form.Controls.Add(server, 0, 3); + if (!bundled) + { + form.Controls.Add(Ui.Label("Адрес сервера", 9, Theme.Muted), 0, 2); + server.Dock = DockStyle.Fill; form.Controls.Add(server, 0, 3); + } form.Controls.Add(Ui.Label("Логин", 9, Theme.Muted), 0, 4); login.Dock = DockStyle.Fill; form.Controls.Add(login, 0, 5); form.Controls.Add(Ui.Label("Пароль", 9, Theme.Muted), 0, 6); @@ -79,7 +83,20 @@ private async Task SignInAsync() ApiClient? client = null; try { - var address = server.Text.Trim(); + if (LocalServer.IsBundled) + { + error.ForeColor = Theme.Muted; + error.Text = "Запуск сервера…"; + if (!await LocalServer.WaitUntilReadyAsync(TimeSpan.FromSeconds(60))) + { + error.ForeColor = Theme.Red; + error.Text = "Не удалось запустить сервер BiStudent. Перезапустите программу."; + return; + } + error.ForeColor = Theme.Red; + error.Text = ""; + } + var address = LocalServer.IsBundled ? LocalServer.Url : server.Text.Trim(); if (!ClientSettings.SameServer(settings.ServerUrl, address)) settings.StoreRefreshToken(null); settings.ServerUrl = address; settings.LastLogin = login.Text.Trim(); @@ -107,6 +124,7 @@ private async Task SignInAsync() finally { signingIn = false; + error.ForeColor = Theme.Red; if (!IsDisposed) { button.Enabled = true; UseWaitCursor = false; } } } diff --git a/BiStudent/Program.cs b/BiStudent/Program.cs index 8d71157..ffee526 100644 --- a/BiStudent/Program.cs +++ b/BiStudent/Program.cs @@ -24,15 +24,30 @@ private static int Main(string[] args) if (screenshots >= 0 && args.Length >= screenshots + 4) return Screenshots(args[screenshots + 1], args[screenshots + 2], args[screenshots + 3]); + // Встроенный сервер живёт вместе с клиентом, поэтому второй экземпляр не запускаем. + using var instance = new Mutex(true, @"Local\BiStudent.Client", out var firstInstance); + if (!firstInstance) + { + MessageBox.Show("BiStudent уже открыт.", "BiStudent", MessageBoxButtons.OK, MessageBoxIcon.Information); + return 0; + } + LocalServer.Start(); while (true) { var settings = ClientSettings.Load(); + if (LocalServer.IsBundled && !ClientSettings.SameServer(settings.ServerUrl, LocalServer.Url)) + { + settings.ServerUrl = LocalServer.Url; + settings.Save(); + } ApiClient? resumed = null; string? loginError = null; if (settings.RememberMe && settings.LoadRefreshToken() is { } token) { try { + if (LocalServer.IsBundled && !Task.Run(() => LocalServer.WaitUntilReadyAsync(TimeSpan.FromSeconds(60))).GetAwaiter().GetResult()) + throw new InvalidOperationException("Не удалось запустить сервер BiStudent. Перезапустите программу."); resumed = new ApiClient(settings); resumed.RefreshTokenChanged += value => { settings.StoreRefreshToken(value); settings.Save(); }; if (!Task.Run(() => resumed.TryResumeAsync(token)).GetAwaiter().GetResult()) diff --git a/installer/BiStudent.cmd b/installer/BiStudent.cmd deleted file mode 100644 index 57ee192..0000000 --- a/installer/BiStudent.cmd +++ /dev/null @@ -1,24 +0,0 @@ -@echo off -chcp 65001 >nul -title BiStudent -cd /d "%~dp0" - -tasklist /fi "imagename eq BiStudent.Server.exe" | find /i "BiStudent.Server.exe" >nul -if errorlevel 1 ( - rem На пустой базе создаются демо-данные: вход teacher / teacher123. - set Demo__Enabled=true - start "BiStudent — сервер (не закрывайте это окно)" /min /d "%~dp0Server" "%~dp0Server\BiStudent.Server.exe" --urls http://0.0.0.0:5080 -) - -echo Запускаю BiStudent, подождите... -for /l %%i in (1,1,60) do ( - curl.exe -s -f -m 2 http://localhost:5080/health >nul 2>&1 && goto ready - timeout /t 1 /nobreak >nul -) -echo. -echo Сервер не ответил за минуту. Откройте свёрнутое окно «BiStudent — сервер» и пришлите текст ошибки. -pause -exit /b 1 - -:ready -start "" /d "%~dp0Client" "%~dp0Client\BiStudent.exe" diff --git a/installer/BiStudent.iss b/installer/BiStudent.iss deleted file mode 100644 index 460371a..0000000 --- a/installer/BiStudent.iss +++ /dev/null @@ -1,52 +0,0 @@ -; Сборка: dotnet publish клиента в out\Client и сервера в out\Server, installer\BiStudent.cmd в out\, -; затем ISCC /DSrc=<путь к out> /DOutDir=<куда положить exe> /DAppVersion=<версия> installer\BiStudent.iss -#ifndef Src - #define Src AddBackslash(SourcePath) + "..\out" -#endif -#ifndef OutDir - #define OutDir AddBackslash(SourcePath) + "..\dist" -#endif -#ifndef AppVersion - #define AppVersion "1.0" -#endif - -[Setup] -AppId={{6B1F2C3A-8E44-4F0B-9D1E-B15DE7E1A001} -AppName=BiStudent -AppVersion={#AppVersion} -AppPublisher=BiStudent -DefaultDirName={localappdata}\Programs\BiStudent -DefaultGroupName=BiStudent -DisableProgramGroupPage=yes -PrivilegesRequired=lowest -OutputDir={#OutDir} -OutputBaseFilename=BiStudent-Setup -Compression=lzma2/max -SolidCompression=yes -LZMANumBlockThreads=4 -ArchitecturesAllowed=x64compatible -ArchitecturesInstallIn64BitMode=x64compatible -UninstallDisplayIcon={app}\Client\BiStudent.exe -WizardStyle=modern -CloseApplications=force - -[Languages] -Name: "ru"; MessagesFile: "compiler:Languages\Russian.isl" - -[Tasks] -Name: "desktopicon"; Description: "Ярлык на рабочем столе"; GroupDescription: "Дополнительно:" - -[Files] -Source: "{#Src}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs - -[Icons] -Name: "{group}\BiStudent"; Filename: "{app}\BiStudent.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\Client\BiStudent.exe"; Flags: runminimized -Name: "{group}\Удалить BiStudent"; Filename: "{uninstallexe}" -Name: "{autodesktop}\BiStudent"; Filename: "{app}\BiStudent.cmd"; WorkingDir: "{app}"; IconFilename: "{app}\Client\BiStudent.exe"; Flags: runminimized; Tasks: desktopicon - -[Run] -Filename: "{app}\BiStudent.cmd"; WorkingDir: "{app}"; Description: "Запустить BiStudent"; Flags: postinstall nowait skipifsilent runminimized shellexec - -[UninstallRun] -Filename: "{sys}\taskkill.exe"; Parameters: "/f /im BiStudent.Server.exe"; Flags: runhidden; RunOnceId: "StopServer" -Filename: "{sys}\taskkill.exe"; Parameters: "/f /im BiStudent.exe"; Flags: runhidden; RunOnceId: "StopClient" From b9b86729db50a859cc719cd29329e15731884878 Mon Sep 17 00:00:00 2001 From: Ligreus Date: Mon, 19 Oct 2026 18:28:06 +0500 Subject: [PATCH 2/6] Ship a zip with BiStudent.exe instead of an installer The release is now BiStudent-win-x64.zip, laid out like the earlier releases: unpack, run BiStudent.exe, sign in. The client is a single-file exe with Models next to it; the server sits in the Server folder. scripts/package.ps1 builds the zip locally and in CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKfFePzqcEUGQQ4JyA4NQn --- .github/workflows/ci.yml | 18 ++++++------------ README.md | 8 +++++--- scripts/package.ps1 | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 scripts/package.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5755e81..73d0609 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,23 +55,17 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - - run: dotnet publish BiStudent/BiStudent.csproj -c Release -r win-x64 --self-contained true -o out/Client - - run: dotnet publish BiStudent.Server/BiStudent.Server.csproj -c Release -r win-x64 --self-contained true -o out/Server - - name: Build installer + - name: Package shell: pwsh - run: | - choco install innosetup -y --no-progress - Copy-Item installer/BiStudent.cmd out/ - & "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" /Q "/DSrc=$PWD\out" "/DOutDir=$PWD\dist" "/DAppVersion=1.0.${{ github.run_number }}" installer/BiStudent.iss - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + run: ./scripts/package.ps1 - uses: actions/upload-artifact@v5 with: - name: BiStudent-Setup - path: dist/BiStudent-Setup.exe + name: BiStudent-win-x64 + path: dist/BiStudent-win-x64.zip - name: Publish release env: GH_TOKEN: ${{ github.token }} run: > - gh release create "v1.0.${{ github.run_number }}" dist/BiStudent-Setup.exe + gh release create "v1.0.${{ github.run_number }}" dist/BiStudent-win-x64.zip --target "${{ github.sha }}" --title "BiStudent 1.0.${{ github.run_number }}" - --notes "Установщик для Windows 10/11 x64: скачайте BiStudent-Setup.exe и запустите. Вход в демо-режиме: teacher / teacher123." + --notes "Windows 10/11 x64: скачайте BiStudent-win-x64.zip, распакуйте целиком и запустите BiStudent.exe. Устанавливать ничего не нужно. На новом компьютере вход teacher / teacher123." diff --git a/README.md b/README.md index fc918b8..33d5f35 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ ## Скачать -**[BiStudent-Setup.exe](https://github.com/Felorez/BiStudent/releases/latest/download/BiStudent-Setup.exe)** для Windows 10/11 x64. Установка без прав администратора, .NET ставить не нужно. После установки на рабочем столе появляется ярлык BiStudent: он запускает сервер и открывает клиент. На новой базе создаются демо-данные, вход **teacher / teacher123**. +**[BiStudent-win-x64.zip](https://github.com/Felorez/BiStudent/releases/latest/download/BiStudent-win-x64.zip)** для Windows 10/11 x64. Распакуйте архив целиком и запустите `BiStudent.exe`: откроется окно входа. Устанавливать ничего не нужно, .NET уже внутри. На новом компьютере создаются демо-данные, вход **teacher / teacher123**. -Установщик не подписан. Если Windows покажет «Windows защитила ваш компьютер», нажмите «Подробнее» → «Выполнить в любом случае». Брандмауэр спросит про BiStudent.Server: разрешите доступ, иначе студенты не откроют сайт регистрации с телефона. +Сервер лежит в папке `Server` рядом с программой. Клиент запускает его сам, без отдельного окна, и закрывает при выходе. Первый запуск занимает до полуминуты, следующие несколько секунд. + +Программа не подписана. Если Windows покажет «Windows защитила ваш компьютер», нажмите «Подробнее» → «Выполнить в любом случае». Брандмауэр спросит про BiStudent.Server: разрешите доступ, иначе студенты не откроют сайт регистрации с телефона. Новая версия собирается автоматически после каждого слияния в `main`, все версии лежат в [Releases](https://github.com/Felorez/BiStudent/releases). @@ -61,7 +63,7 @@ cd BiStudent | `docker compose up -d --build` | сервер в Docker на `http://localhost:5080`, база в томе `bistudent-data` | | `dotnet test --solution BiStudent.slnx` | тесты сервера (`BiStudent.Tests`) и клиента (`BiStudent.Client.Tests`) | | `node --test tests/site/register.test.cjs` | тесты сайта регистрации лица | -| `ISCC installer\BiStudent.iss` | собрать установщик в `dist/` из `out/Client` и `out/Server` (порядок сборки в начале файла) | +| `.\scripts\package.ps1` | собрать `dist/BiStudent-win-x64.zip`: клиент одним exe, модели и встроенный сервер | | `BiStudent.exe --self-test` | без сервера проверяет модели, камеры и окно входа, результат в `diagnostics/result.txt` | | `BiStudent.exe --screenshots <адрес> <логин> <пароль>` | входит на сервер и сохраняет скриншоты всех разделов в `diagnostics/` | diff --git a/scripts/package.ps1 b/scripts/package.ps1 new file mode 100644 index 0000000..d7b38e4 --- /dev/null +++ b/scripts/package.ps1 @@ -0,0 +1,38 @@ +<# +.SYNOPSIS + Собирает dist/BiStudent-win-x64.zip: распаковал, запустил BiStudent.exe, вошёл. + В корне архива клиент одним файлом и модели, в папке Server встроенный сервер, который клиент запускает сам. +#> +param([string]$Output = 'dist') + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$stage = Join-Path $root 'out\BiStudent' +$output = if ([IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $root $Output } +$zip = Join-Path $output 'BiStudent-win-x64.zip' +$single = @('-c', 'Release', '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=true', + '-p:IncludeNativeLibrariesForSelfExtract=true', '-p:DebugType=None', '-p:DebugSymbols=false') + +if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } +dotnet publish (Join-Path $root 'BiStudent\BiStudent.csproj') @single -o $stage +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +dotnet publish (Join-Path $root 'BiStudent.Server\BiStudent.Server.csproj') @single -o (Join-Path $stage 'Server') +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +@' +BiStudent — электронный журнал преподавателя + +1. Распакуйте архив целиком в любую папку (не запускайте из самого архива). +2. Запустите BiStudent.exe. +3. Войдите: на новом компьютере создаются демо-данные, логин teacher, пароль teacher123. + +Нужна Windows 10/11 x64, устанавливать .NET не нужно. Папки Models и Server должны лежать рядом с BiStudent.exe. +Если Windows покажет «Windows защитила ваш компьютер», нажмите «Подробнее» → «Выполнить в любом случае». +Если брандмауэр спросит про BiStudent.Server, разрешите доступ: через него студенты регистрируют лица с телефона. +Данные хранятся в C:\ProgramData\BiStudent и не пропадают при обновлении программы. +'@ | Set-Content (Join-Path $stage 'README.txt') -Encoding utf8 + +New-Item -ItemType Directory -Force $output | Out-Null +if (Test-Path $zip) { Remove-Item $zip -Force } +Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -CompressionLevel Optimal +Write-Host "Готово: $zip ($([math]::Round((Get-Item $zip).Length / 1MB)) МБ)" From 0c8f5b99dcf64298523f572e7947e12be6621811 Mon Sep 17 00:00:00 2001 From: Ligreus Date: Mon, 19 Oct 2026 18:54:56 +0500 Subject: [PATCH 3/6] Make the packaged app start fast - Native libraries ship next to the exe instead of being self-extracted: the first launch spent ~20 s unpacking ~200 MB into %TEMP%. Now the login window opens in about a second, first run included. - ReadyToRun for the client and server, and the request delegate generator for minimal APIs: the server is ready in ~1.8 s instead of ~4.5 s. - The client no longer pulls in WPF (OpenCvSharp4.Windows -> OpenCvSharp4.runtime.win), and the unused OpenCV ffmpeg plugin is not published. The client exe went from 259 MB to 130 MB. - The server listens on all IPv4 and IPv6 addresses. Bound to 0.0.0.0, every new connection to localhost first waited ~2 s for ::1 to be refused. - The client checks that the port is listening before polling /health, since a refused connection on Windows also takes ~2 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKfFePzqcEUGQQ4JyA4NQn --- BiStudent.Server/BiStudent.Server.csproj | 2 ++ BiStudent/BiStudent.csproj | 2 +- BiStudent/LocalServer.cs | 6 +++++- Directory.Build.targets | 8 ++++++++ scripts/package.ps1 | 4 +++- scripts/run-demo.ps1 | 2 +- 6 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 Directory.Build.targets diff --git a/BiStudent.Server/BiStudent.Server.csproj b/BiStudent.Server/BiStudent.Server.csproj index 44e6d6e..d4dea48 100644 --- a/BiStudent.Server/BiStudent.Server.csproj +++ b/BiStudent.Server/BiStudent.Server.csproj @@ -4,6 +4,8 @@ enable enable BiStudent.Server + + true diff --git a/BiStudent/BiStudent.csproj b/BiStudent/BiStudent.csproj index 8908c0a..1977242 100644 --- a/BiStudent/BiStudent.csproj +++ b/BiStudent/BiStudent.csproj @@ -10,7 +10,7 @@ $(NoWarn);CA1416;WFO1000 - + diff --git a/BiStudent/LocalServer.cs b/BiStudent/LocalServer.cs index 9ec11c4..1b746fb 100644 --- a/BiStudent/LocalServer.cs +++ b/BiStudent/LocalServer.cs @@ -64,7 +64,7 @@ private static async Task LaunchAsync(string executable) { StartInfo = new ProcessStartInfo(executable) { - Arguments = "--urls http://0.0.0.0:5080", + Arguments = "--urls http://*:5080", WorkingDirectory = Path.GetDirectoryName(executable)!, UseShellExecute = false, CreateNoWindow = true @@ -89,6 +89,10 @@ private static async Task LaunchAsync(string executable) private static async Task IsHealthyAsync(TimeSpan timeout, CancellationToken cancellationToken) { + // Windows отклоняет подключение к закрытому порту только через ~2 с, поэтому сначала смотрим, слушает ли его кто-нибудь. + var port = new Uri(Url).Port; + if (!System.Net.NetworkInformation.IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Any(x => x.Port == port)) + return false; using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); requestTimeout.CancelAfter(timeout); try diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..cdd22ad --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/scripts/package.ps1 b/scripts/package.ps1 index d7b38e4..feb3f72 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -10,8 +10,10 @@ $root = Split-Path -Parent $PSScriptRoot $stage = Join-Path $root 'out\BiStudent' $output = if ([IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $root $Output } $zip = Join-Path $output 'BiStudent-win-x64.zip' +# Нативные библиотеки (OpenCV, SQLite) лежат рядом с exe: при самораспаковке первый запуск ждал распаковки ~200 МБ во временную папку. +# ReadyToRun компилирует код заранее, чтобы клиент и сервер не тратили секунды на JIT при каждом старте. $single = @('-c', 'Release', '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=true', - '-p:IncludeNativeLibrariesForSelfExtract=true', '-p:DebugType=None', '-p:DebugSymbols=false') + '-p:PublishReadyToRun=true', '-p:DebugType=None', '-p:DebugSymbols=false') if (Test-Path $stage) { Remove-Item $stage -Recurse -Force } dotnet publish (Join-Path $root 'BiStudent\BiStudent.csproj') @single -o $stage diff --git a/scripts/run-demo.ps1 b/scripts/run-demo.ps1 index 4bab441..b0bb711 100644 --- a/scripts/run-demo.ps1 +++ b/scripts/run-demo.ps1 @@ -39,7 +39,7 @@ if ($Demo) { } Write-Host "Запуск сервера на порту $port…" -$serverProcess = Start-Process dotnet -ArgumentList @('run', '--project', $server, '-c', 'Release', '--no-build', '--no-launch-profile', '--urls', "http://0.0.0.0:$port") ` +$serverProcess = Start-Process dotnet -ArgumentList @('run', '--project', $server, '-c', 'Release', '--no-build', '--no-launch-profile', '--urls', "http://*:$port") ` -WorkingDirectory $server -WindowStyle Minimized -PassThru $ready = $false From 7040f2585542a9a4daed6dd9832eb171789f3d60 Mon Sep 17 00:00:00 2001 From: Ligreus Date: Mon, 19 Oct 2026 19:17:19 +0500 Subject: [PATCH 4/6] Fix crash after closing a lesson menu in the schedule The lesson context menu was disposed inside its own Closed handler. WinForms was still closing it and then touched the disposed menu, which killed the app with ObjectDisposedException after a click outside the menu or on one of its items. The menu is now disposed after the close completes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKfFePzqcEUGQQ4JyA4NQn --- BiStudent/Pages/SchedulePage.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BiStudent/Pages/SchedulePage.cs b/BiStudent/Pages/SchedulePage.cs index 0a44bbd..5224fb3 100644 --- a/BiStudent/Pages/SchedulePage.cs +++ b/BiStudent/Pages/SchedulePage.cs @@ -124,7 +124,8 @@ private void ShowMenu(LessonCard card, LessonDto lesson) if (!Ui.Confirm(FindForm(), "Удалить пару", "Шаблон пары и все её отметки будут удалены. Чтобы убрать одно занятие, лучше отмените его на дату.", "Удалить", true)) return; await ExecuteAsync(async () => { await Api.DeleteSlotAsync(lesson.Slot.Id); await RefreshAsync(); }); }; - menu.Closed += (_, _) => menu.Dispose(); + // Удалять меню прямо в Closed нельзя: WinForms ещё закрывает его и падает на удалённом объекте. + menu.Closed += (_, _) => { if (IsHandleCreated) BeginInvoke(menu.Dispose); }; menu.Show(card, new Point(8, card.Height - 4)); } From c9ba64d78ff344e5b53b2a38e54b9dc19f13b0b2 Mon Sep 17 00:00:00 2001 From: Ligreus Date: Mon, 19 Oct 2026 19:31:43 +0500 Subject: [PATCH 5/6] Close the remaining ways the client could crash on exit or sign-out - ApiClient.Dispose no longer disposes the token semaphore: a refresh still in flight after sign-out would call Release() on a disposed object. - ClientSettings.Save logs IO errors instead of throwing. A locked or read-only client.json killed the app from Program.Main or broke a request during token refresh. - MainForm does not switch pages once closing has started: closing the window while the camera was stopping and clicking a section went on building a page inside the disposed form. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VKfFePzqcEUGQQ4JyA4NQn --- BiStudent/Api/ApiClient.cs | 4 +++- BiStudent/Api/ClientSettings.cs | 12 ++++++++++-- BiStudent/MainForm.cs | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/BiStudent/Api/ApiClient.cs b/BiStudent/Api/ApiClient.cs index 6a491f1..ba4202e 100644 --- a/BiStudent/Api/ApiClient.cs +++ b/BiStudent/Api/ApiClient.cs @@ -479,5 +479,7 @@ private void RaiseUnauthorized() else _uiContext.Post(_ => { if (!Volatile.Read(ref _ended)) Unauthorized?.Invoke(); }, null); } - public void Dispose() { EndSession(); _tokenLock.Dispose(); _http.Dispose(); } + // _tokenLock не освобождаем: незавершённый запрос обновления токена ещё вызовет Release(), + // а SemaphoreSlim без AvailableWaitHandle не держит системных ресурсов. + public void Dispose() { EndSession(); _http.Dispose(); } } diff --git a/BiStudent/Api/ClientSettings.cs b/BiStudent/Api/ClientSettings.cs index ba7a661..5a6ec40 100644 --- a/BiStudent/Api/ClientSettings.cs +++ b/BiStudent/Api/ClientSettings.cs @@ -59,9 +59,17 @@ public void StoreRefreshToken(string? token) ProtectedData.Protect(Encoding.UTF8.GetBytes(token), TokenEntropy, DataProtectionScope.CurrentUser)); } + // Не сохранившиеся настройки (файл занят или недоступен) не должны ронять приложение посреди входа или занятия. public void Save() { - Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); - File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions), Encoding.UTF8); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); + File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions), Encoding.UTF8); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + CrashGuard.Log(ex, "settings"); + } } } diff --git a/BiStudent/MainForm.cs b/BiStudent/MainForm.cs index 0fcec4e..6902f92 100644 --- a/BiStudent/MainForm.cs +++ b/BiStudent/MainForm.cs @@ -119,7 +119,7 @@ private static string Initials(string name) => internal async Task ShowPageAsync(string key) { - if (switching || IsDisposed) return; + if (switching || leaving || IsDisposed) return; switching = true; if (api.Teacher is { } teacher && userName is not null && userAvatar is not null) { userName.Text = teacher.FullName; userAvatar.Text = Initials(teacher.FullName); } try @@ -127,6 +127,8 @@ internal async Task ShowPageAsync(string key) if (current is not null) { await current.OnLeaveAsync(); + // Пока страница останавливалась (например, камера), окно могли начать закрывать. + if (leaving || IsDisposed) return; host.Controls.Remove(current); current.Dispose(); current = null; From 6a11a5dd06319501f559e454fae7b4e6f4423b70 Mon Sep 17 00:00:00 2001 From: Ligreus Date: Mon, 19 Oct 2026 21:13:43 +0500 Subject: [PATCH 6/6] Fix clipped and overlapping UI at 1180x720 and up --- BiStudent/MainForm.cs | 4 +- BiStudent/ModernControls.cs | 61 ++++++++++++++++++++++--- BiStudent/Pages/AttendanceList.cs | 3 +- BiStudent/Pages/CameraPage.cs | 15 +++--- BiStudent/Pages/EnrollmentCodeDialog.cs | 3 +- BiStudent/Pages/EnrollmentDialog.cs | 4 +- BiStudent/Pages/ExcuseDialog.cs | 16 ++++--- BiStudent/Pages/GradeEditor.cs | 10 ++-- BiStudent/Pages/GroupsPage.cs | 12 +++-- BiStudent/Pages/ReportPage.cs | 19 +++++--- BiStudent/Pages/ScheduleImportDialog.cs | 9 +++- BiStudent/Pages/SchedulePage.cs | 23 +++++++--- BiStudent/Pages/SlotDialog.cs | 1 + BiStudent/Pages/TodayPage.cs | 31 ++++++++----- 14 files changed, 150 insertions(+), 61 deletions(-) diff --git a/BiStudent/MainForm.cs b/BiStudent/MainForm.cs index 6902f92..2a1b4c4 100644 --- a/BiStudent/MainForm.cs +++ b/BiStudent/MainForm.cs @@ -43,7 +43,7 @@ public MainForm(ApiClient api) private void BuildShell() { - var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 240, BackColor = Theme.Sidebar, RowCount = 3, Padding = new Padding(16, 22, 16, 16) }; + var sidebar = new TableLayoutPanel { Dock = DockStyle.Left, Width = 250, BackColor = Theme.Sidebar, RowCount = 3, Padding = new Padding(16, 22, 16, 16) }; sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 84)); sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); @@ -61,7 +61,7 @@ private void BuildShell() foreach (var (key, text, icon) in Pages) { if (key == "admin" && api.Teacher?.IsAdmin != true) continue; - var button = new ModernButton { Text = text, Icon = icon, Width = 206, Height = 44, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, key == "report" ? 14 : 0, 0, 6), Dashed = key == "report", BackColor = Theme.Sidebar }; + var button = new ModernButton { Text = text, Icon = icon, Width = 218, Height = 44, TextAlign = ContentAlignment.MiddleLeft, Margin = new Padding(0, key == "report" ? 14 : 0, 0, 6), Dashed = key == "report", BackColor = Theme.Sidebar }; button.Click += async (_, _) => await ShowPageAsync(key); nav[key] = button; menu.Controls.Add(button); diff --git a/BiStudent/ModernControls.cs b/BiStudent/ModernControls.cs index 2a1bc40..6974a14 100644 --- a/BiStudent/ModernControls.cs +++ b/BiStudent/ModernControls.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Drawing.Drawing2D; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace BiStudent; @@ -43,6 +44,7 @@ public DarkForm() protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); + Ui.DarkScrollbars(this); var enabled = 1; if (DwmSetWindowAttribute(Handle, 20, ref enabled, sizeof(int)) != 0) DwmSetWindowAttribute(Handle, 19, ref enabled, sizeof(int)); @@ -210,6 +212,7 @@ public KpiTile(string caption, float valueSize = 24) Padding = new Padding(18, 14, 18, 12); Margin = new Padding(0, 0, 14, 0); title.Text = caption; + title.AutoEllipsis = true; value.Font = Theme.Font(valueSize, FontStyle.Bold); var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 3, BackColor = Color.Transparent }; layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 22)); @@ -332,6 +335,7 @@ internal abstract class Page : UserControl { private readonly Label title = Ui.Label("", 22, Theme.Text, FontStyle.Bold); private readonly Label subtitle = Ui.Label("", 9.5f, Theme.Muted); + private readonly TableLayoutPanel headerText = new() { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Background }; private bool busy; protected ApiClient Api { get; } protected FlowLayoutPanel Actions { get; } = new() { Dock = DockStyle.Right, AutoSize = true, FlowDirection = FlowDirection.LeftToRight, WrapContents = false, BackColor = Theme.Background, Padding = new Padding(0, 14, 0, 0) }; @@ -347,16 +351,23 @@ protected Page(ApiClient api, string pageTitle, string pageSubtitle) Padding = new Padding(30, 22, 30, 22); title.Text = pageTitle; subtitle.Text = pageSubtitle; - var text = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Background }; - text.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); - text.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); - text.Controls.Add(title, 0, 0); text.Controls.Add(subtitle, 0, 1); + subtitle.AutoEllipsis = true; + headerText.MinimumSize = new Size(TextRenderer.MeasureText(pageTitle, title.Font).Width + 16, 0); + headerText.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + // Одна строка: длинный подзаголовок обрезается многоточием, а не уходит второй строкой под контент. + headerText.RowStyles.Add(new RowStyle(SizeType.Absolute, 22)); + subtitle.Dock = DockStyle.Top; subtitle.Height = 22; + headerText.Controls.Add(title, 0, 0); headerText.Controls.Add(subtitle, 0, 1); var head = new Panel { Dock = DockStyle.Top, Height = 78, BackColor = Theme.Background }; - head.Controls.Add(text); head.Controls.Add(Actions); + head.Controls.Add(headerText); head.Controls.Add(Actions); Controls.Add(Body); Controls.Add(head); } - public string Title { get => title.Text; protected set => title.Text = value; } + public string Title + { + get => title.Text; + protected set { title.Text = value; headerText.MinimumSize = new Size(TextRenderer.MeasureText(value, title.Font).Width + 16, 0); } + } public string Subtitle { get => subtitle.Text; protected set => subtitle.Text = value; } public abstract Task LoadAsync(); @@ -384,6 +395,30 @@ protected async Task RunAsync(Func action, string? success = null) internal static class Ui { + private static readonly ConditionalWeakTable scrollbarHooks = new(); + + [DllImport("uxtheme.dll", CharSet = CharSet.Unicode)] + private static extern int SetWindowTheme(IntPtr handle, string? appName, string? idList); + + public static void DarkScrollbars(Control control) + { + if (scrollbarHooks.TryGetValue(control, out _)) return; + scrollbarHooks.Add(control, new object()); + if (control is ScrollableControl { AutoScroll: true } or DataGridView or ListView or ListBox or TextBox { Multiline: true }) + { + void Apply() + { + try { SetWindowTheme(control.Handle, "DarkMode_Explorer", null); } + catch (DllNotFoundException) { } + catch (EntryPointNotFoundException) { } + } + control.HandleCreated += (_, _) => Apply(); + if (control.IsHandleCreated) Apply(); + } + control.ControlAdded += (_, e) => { if (e.Control is { } added) DarkScrollbars(added); }; + foreach (Control child in control.Controls) DarkScrollbars(child); + } + /// Ждёт фоновую задачу не дольше timeout и гасит её исключения: зависший драйвер камеры не должен блокировать окно. public static async Task SettleAsync(Task? task, int timeout = 3000) { @@ -518,7 +553,7 @@ protected override void OnPaint(PaintEventArgs e) internal class DialogShell : DarkForm { - private readonly FlowLayoutPanel buttons = new() { Dock = DockStyle.Bottom, Height = 58, FlowDirection = FlowDirection.RightToLeft, BackColor = Theme.Background, Padding = new Padding(16, 8, 6, 8) }; + private readonly FlowLayoutPanel buttons = new() { Dock = DockStyle.Bottom, Height = 58, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, BackColor = Theme.Background, Padding = new Padding(16, 8, 6, 8) }; public Panel Content { get; } = new() { Dock = DockStyle.Fill, BackColor = Theme.Background, Padding = new Padding(24, 8, 24, 8) }; public DialogShell(string title, int width, int height) @@ -533,6 +568,18 @@ public DialogShell(string title, int width, int height) Controls.Add(Content); Controls.Add(buttons); Controls.Add(heading); } + protected override void OnLoad(EventArgs e) + { + var width = buttons.Padding.Horizontal + buttons.Controls.Cast().Sum(button => button.Width + button.Margin.Horizontal); + if (width > ClientSize.Width) + { + ClientSize = new Size(width, ClientSize.Height); + if (StartPosition == FormStartPosition.CenterParent && Owner is { } owner) + Location = new Point(owner.Left + (owner.Width - Width) / 2, owner.Top + (owner.Height - Height) / 2); + } + base.OnLoad(e); + } + public ModernButton AddButton(string text, DialogResult result, bool primary = false) { var button = new ModernButton { Text = text, Primary = primary, Width = Math.Max(120, TextRenderer.MeasureText(text, Theme.Font(10, FontStyle.Bold)).Width + 40), Height = 40 }; diff --git a/BiStudent/Pages/AttendanceList.cs b/BiStudent/Pages/AttendanceList.cs index d87c9d0..e78b582 100644 --- a/BiStudent/Pages/AttendanceList.cs +++ b/BiStudent/Pages/AttendanceList.cs @@ -56,7 +56,8 @@ public void Flash(int studentId) private void Relayout() { - canvas.Width = Math.Max(10, ClientSize.Width); + var vertical = rows.Count * RowHeight + 4 > ClientSize.Height; + canvas.Width = Math.Max(10, ClientSize.Width - (vertical && !VerticalScroll.Visible ? SystemInformation.VerticalScrollBarWidth : 0) - 1); canvas.Height = Math.Max(ClientSize.Height, rows.Count * RowHeight + 4); } diff --git a/BiStudent/Pages/CameraPage.cs b/BiStudent/Pages/CameraPage.cs index 3bea361..d4d073e 100644 --- a/BiStudent/Pages/CameraPage.cs +++ b/BiStudent/Pages/CameraPage.cs @@ -89,15 +89,18 @@ public CameraPage(ApiClient api) : base(api, "Отметка посещаемо students.Dock = DockStyle.Fill; students.Margin = Padding.Empty; - var listArea = new Panel { Dock = DockStyle.Fill, BackColor = Theme.Surface }; - finish.Dock = DockStyle.Bottom; + var listArea = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, BackColor = Theme.Surface }; + listArea.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + listArea.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + finish.Dock = DockStyle.Fill; finish.Height = 42; - listArea.Controls.Add(attendance); - listArea.Controls.Add(finish); + listArea.Controls.Add(attendance, 0, 0); + listArea.Controls.Add(finish, 0, 1); students.Body.Controls.Add(listArea); var columns = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, BackColor = Theme.Background }; - columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 57)); - columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 43)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 470)); + columns.Resize += (_, _) => columns.ColumnStyles[1].Width = Math.Max(470, columns.ClientSize.Width * .43f); columns.Controls.Add(left, 0, 0); columns.Controls.Add(students, 1, 0); Body.Controls.Add(columns); diff --git a/BiStudent/Pages/EnrollmentCodeDialog.cs b/BiStudent/Pages/EnrollmentCodeDialog.cs index 1c7074e..f23da55 100644 --- a/BiStudent/Pages/EnrollmentCodeDialog.cs +++ b/BiStudent/Pages/EnrollmentCodeDialog.cs @@ -26,7 +26,8 @@ public EnrollmentCodeDialog(ApiClient api, int groupId, string groupName) this.api = api; this.groupId = groupId; Text = "Код регистрации лица"; - ClientSize = new Size(780, 740); + // На низких экранах (1366×768) окно не выше рабочей области, остальное — прокруткой. + ClientSize = new Size(780, Math.Min(800, Screen.PrimaryScreen!.WorkingArea.Height - 60)); FormBorderStyle = FormBorderStyle.FixedDialog; StartPosition = FormStartPosition.CenterParent; MaximizeBox = false; MinimizeBox = false; ShowInTaskbar = false; diff --git a/BiStudent/Pages/EnrollmentDialog.cs b/BiStudent/Pages/EnrollmentDialog.cs index 1b404d9..89b9376 100644 --- a/BiStudent/Pages/EnrollmentDialog.cs +++ b/BiStudent/Pages/EnrollmentDialog.cs @@ -92,8 +92,8 @@ public EnrollmentDialog(ApiClient api, StudentDto student) Dock = DockStyle.Fill, ColumnCount = 2, Padding = new Padding(18), BackColor = Theme.Background }; - columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 62)); - columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 38)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 60)); + columns.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 40)); columns.Controls.Add(left, 0, 0); columns.Controls.Add(right, 1, 0); Controls.Add(columns); diff --git a/BiStudent/Pages/ExcuseDialog.cs b/BiStudent/Pages/ExcuseDialog.cs index 5e1e8ca..d49c57e 100644 --- a/BiStudent/Pages/ExcuseDialog.cs +++ b/BiStudent/Pages/ExcuseDialog.cs @@ -4,7 +4,7 @@ namespace BiStudent; internal sealed class ExcuseDialog : IDisposable { - private readonly DialogShell shell = new("Уважительная причина", 540, 430); + private readonly DialogShell shell = new("Уважительная причина", 540, 530); private readonly RadioButton[] reasons = new RadioButton[4]; private readonly CheckBox document = new() { Text = "Подтверждающий документ предоставлен (справка, заявление, приказ)", AutoSize = true, ForeColor = Theme.Text, BackColor = Theme.Background }; private readonly TextBox comment = Ui.Input("Комментарий"); @@ -12,10 +12,14 @@ internal sealed class ExcuseDialog : IDisposable public ExcuseDialog(string studentName, ExcuseReason? reason = null, bool hasDocument = false, string? initialComment = null) { - var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 9, BackColor = Theme.Background }; - foreach (var height in new[] { 30, 24, 35, 35, 35, 35, 45, 24, 80 }) + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 10, BackColor = Theme.Background }; + foreach (var height in new[] { 30, 24, 35, 35, 35, 35, 45, 24 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); - layout.Controls.Add(Ui.Label(studentName, 10, Theme.Muted), 0, 0); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 22)); + var name = Ui.Label(studentName, 10, Theme.Muted); + name.AutoEllipsis = true; + layout.Controls.Add(name, 0, 0); layout.Controls.Add(Ui.Label("Причина", 9, Theme.Muted), 0, 1); var values = Enum.GetValues(); for (var i = 0; i < values.Length; i++) @@ -36,15 +40,13 @@ public ExcuseDialog(string studentName, ExcuseReason? reason = null, bool hasDoc comment.Text = initialComment ?? ""; comment.Dock = DockStyle.Fill; layout.Controls.Add(comment, 0, 8); - var bottom = new Panel { Dock = DockStyle.Bottom, Height = 20, BackColor = Theme.Background }; counter.Dock = DockStyle.Right; counter.Width = 70; counter.TextAlign = ContentAlignment.MiddleRight; - bottom.Controls.Add(counter); + layout.Controls.Add(counter, 0, 9); comment.TextChanged += (_, _) => counter.Text = $"{comment.TextLength}/500"; counter.Text = $"{comment.TextLength}/500"; shell.Content.Controls.Add(layout); - shell.Content.Controls.Add(bottom); shell.AddButton("Сохранить", DialogResult.OK, primary: true); shell.AddButton("Отмена", DialogResult.Cancel); } diff --git a/BiStudent/Pages/GradeEditor.cs b/BiStudent/Pages/GradeEditor.cs index 7aa8639..8956156 100644 --- a/BiStudent/Pages/GradeEditor.cs +++ b/BiStudent/Pages/GradeEditor.cs @@ -34,7 +34,7 @@ public GradeEditor(ApiClient api, JournalColumnDto column, JournalCellDto cell, reason = attendance?.Reason ?? cell.Reason; comment = attendance?.Comment ?? cell.Comment; hasDocument = attendance?.HasDocument ?? false; - shell = new DialogShell(attendanceOnly ? "Посещаемость" : "Отметка и балл", 400, attendanceOnly ? 270 : column.Kind == JournalColumnKind.Lesson && !scoreOnly ? 330 : 220); + shell = new DialogShell(attendanceOnly ? "Посещаемость" : "Отметка и балл", 460, attendanceOnly ? 320 : column.Kind == JournalColumnKind.Lesson && !scoreOnly ? 380 : 270); Build(cell.Score, column.Kind == JournalColumnKind.Lesson && !scoreOnly, !attendanceOnly); } @@ -45,7 +45,7 @@ public GradeEditor(ApiClient api, int groupId, string subject, int studentId, st this.subject = subject; this.studentId = studentId; this.studentName = studentName; - shell = new DialogShell("Итоговый балл", 400, 220); + shell = new DialogShell("Итоговый балл", 460, 270); Build(final, false); } @@ -54,7 +54,9 @@ public GradeEditor(ApiClient api, int groupId, string subject, int studentId, st private void Build(int? initialScore, bool attendance, bool scoreEnabled = true) { var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, BackColor = Theme.Background }; - layout.Controls.Add(Ui.Label(studentName, 10, Theme.Muted)); + var name = Ui.Label(studentName, 10, Theme.Muted); + name.AutoEllipsis = true; + layout.Controls.Add(name); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 30)); if (attendance) { @@ -64,7 +66,7 @@ private void Build(int? initialScore, bool attendance, bool scoreEnabled = true) ("П", AttendanceStatus.Present), ("Н", AttendanceStatus.Absent), ("Н — уважительная…", AttendanceStatus.Excused) }) { - var button = new ModernButton { Text = text, Width = value == AttendanceStatus.Excused ? 172 : 62, Height = 38, Selected = status == value }; + var button = new ModernButton { Text = text, Width = value == AttendanceStatus.Excused ? TextRenderer.MeasureText(text, Theme.Font(10, FontStyle.Bold)).Width + 24 : 62, Height = 38, Selected = status == value }; button.Click += async (_, _) => await SaveStatusAsync(value); buttons.Controls.Add(button); statusButtons[value] = button; diff --git a/BiStudent/Pages/GroupsPage.cs b/BiStudent/Pages/GroupsPage.cs index 85226ba..8c08b03 100644 --- a/BiStudent/Pages/GroupsPage.cs +++ b/BiStudent/Pages/GroupsPage.cs @@ -24,10 +24,11 @@ internal sealed class GroupsPage : Page public GroupsPage(ApiClient api) : base(api, "Группы", "Списки групп и регистрация лиц студентов") { - var import = new ModernButton { Text = "Импорт списков", Width = 140 }; - var code = new ModernButton { Text = "Код регистрации", Width = 185 }; - var addGroup = new ModernButton { Text = "+ Группа", Width = 130 }; - var addStudent = new ModernButton { Text = "+ Студент", Width = 140, Primary = true }; + var import = new ModernButton { Text = "Импорт списков" }; + var code = new ModernButton { Text = "Код регистрации" }; + var addGroup = new ModernButton { Text = "+ Группа" }; + var addStudent = new ModernButton { Text = "+ Студент", Primary = true }; + foreach (var button in new[] { import, code, submissionsButton, addGroup, addStudent }) { button.Width = TextRenderer.MeasureText(button.Text, button.Font).Width + 16; button.Margin = new Padding(0, 0, 4, 0); } Actions.Controls.Add(import); Actions.Controls.Add(code); Actions.Controls.Add(submissionsButton); Actions.Controls.Add(addGroup); Actions.Controls.Add(addStudent); import.Click += async (_, _) => await ImportAsync(); @@ -101,7 +102,10 @@ private async Task ReloadAsync(List? loaded = null) if (IsDisposed) return; var submissions = await Api.FaceSubmissionsAsync(); if (!IsDisposed) + { submissionsButton.Text = submissions.Count == 0 ? "Заявки на регистрацию" : $"Заявки на регистрацию ({submissions.Count})"; + submissionsButton.Width = TextRenderer.MeasureText(submissionsButton.Text, submissionsButton.Font).Width + 16; + } } private void RenderGroups() diff --git a/BiStudent/Pages/ReportPage.cs b/BiStudent/Pages/ReportPage.cs index 15d1a75..67e02e3 100644 --- a/BiStudent/Pages/ReportPage.cs +++ b/BiStudent/Pages/ReportPage.cs @@ -10,6 +10,10 @@ internal sealed class ReportPage : Page private readonly Panel content = new() { BackColor = Theme.Background }; private readonly Banner banner = new(); private readonly Label period = Ui.Label("", 9, Theme.Violet, FontStyle.Bold); + private readonly Label heading = Ui.Label("Что автоматизация даёт педсоставу", 22, Theme.Text, FontStyle.Bold); + private readonly Label description = Ui.Label( + "BiStudent снимает с преподавателя перекличку, сведение журнала и контроль пропусков.\nНиже — фактические данные одного преподавателя и расчёт для всего колледжа.", + 10.5f, Theme.Text); private readonly KpiTile saved = new("Сэкономлено преподавателем", 30) { Accent = true }; private readonly KpiTile camera = new("Отмечено камерой", 30); private readonly KpiTile exports = new("Выгрузок журнала", 30); @@ -53,14 +57,10 @@ public ReportPage(ApiClient api) : base(api, "Отчёт", "Посещаемос banner.Height = 216; period.Dock = DockStyle.None; period.SetBounds(26, 20, 560, 28); - var heading = Ui.Label("Что автоматизация даёт педсоставу", 22, Theme.Text, FontStyle.Bold); heading.Dock = DockStyle.None; - heading.SetBounds(26, 57, 790, 48); - var description = Ui.Label( - "BiStudent снимает с преподавателя перекличку, сведение журнала и контроль пропусков.\nНиже — фактические данные одного преподавателя и расчёт для всего колледжа.", - 10.5f, Theme.Text); + heading.SetBounds(26, 74, 790, 48); description.Dock = DockStyle.None; - description.SetBounds(28, 119, 830, 70); + description.SetBounds(28, 130, 830, 70); saveButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; printButton.Anchor = AnchorStyles.Top | AnchorStyles.Right; saveButton.Top = printButton.Top = 25; @@ -69,6 +69,8 @@ public ReportPage(ApiClient api) : base(api, "Отчёт", "Посещаемос banner.Controls.Add(description); banner.Controls.Add(saveButton); banner.Controls.Add(printButton); + saveButton.BringToFront(); + printButton.BringToFront(); content.Controls.Add(banner); for (var i = 0; i < 4; i++) kpis.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); @@ -175,11 +177,14 @@ private static SmoothPanel ComparisonColumn(string title, Color back, (Label Hea private void LayoutContent() { - var width = Math.Max(920, scroll.ClientSize.Width - 20); + var width = Math.Max(1, scroll.ClientSize.Width - SystemInformation.VerticalScrollBarWidth - 2); var y = 0; banner.SetBounds(0, y, width, 216); printButton.Left = width - printButton.Width - 22; saveButton.Left = printButton.Left - saveButton.Width - 10; + period.Width = Math.Max(1, saveButton.Left - period.Left - 16); + heading.Width = Math.Max(1, width - heading.Left - 28); + description.Width = Math.Max(1, width - description.Left - 28); y += 234; kpis.SetBounds(0, y, width, 150); y += 168; diff --git a/BiStudent/Pages/ScheduleImportDialog.cs b/BiStudent/Pages/ScheduleImportDialog.cs index 9e3f28d..a819a11 100644 --- a/BiStudent/Pages/ScheduleImportDialog.cs +++ b/BiStudent/Pages/ScheduleImportDialog.cs @@ -42,8 +42,9 @@ public ScheduleImportDialog(ApiClient api, byte[] file, ScheduleImportPreviewDto var listCard = new SmoothPanel { Dock = DockStyle.Fill, Padding = new Padding(12), BackColor = Theme.Surface, Margin = new Padding(0, 4, 0, 4) }; listCard.Controls.Add(list); layout.Controls.Add(listCard, 0, 1); - layout.Controls.Add(Ui.Label("Преподаватели: " + string.Join(", ", preview.Teachers) + - ".\nДля новых преподавателей будут созданы учётные записи с временными паролями.", 9, Theme.Muted), 0, 2); + var teacherNames = string.Join(", ", preview.Teachers); + layout.Controls.Add(Ui.Label("Преподаватели: " + teacherNames + + (teacherNames.EndsWith('.') ? "" : ".") + "\nДля новых преподавателей будут созданы учётные записи с временными паролями.", 9, Theme.Muted), 0, 2); if (preview.Warnings.Count > 0) layout.Controls.Add(Ui.Label($"Не удалось разобрать записей: {preview.Warnings.Count} — они будут пропущены и показаны после импорта.", 9, Theme.Yellow), 0, 3); error.TextAlign = ContentAlignment.MiddleLeft; @@ -91,6 +92,10 @@ public static void ShowResult(IWin32Window? owner, ScheduleImportResultDto resul var grid = Ui.Grid(); grid.Dock = DockStyle.Fill; grid.Columns.Add("name", "ФИО"); grid.Columns.Add("login", "Логин"); grid.Columns.Add("password", "Временный пароль"); grid.Columns.Add("state", "Статус"); + grid.Columns["name"]!.FillWeight = 43; + grid.Columns["login"]!.FillWeight = 17; + grid.Columns["password"]!.FillWeight = 17; + grid.Columns["state"]!.FillWeight = 23; grid.Columns["password"]!.DefaultCellStyle.Font = new Font("Consolas", 11, FontStyle.Bold); foreach (var teacher in result.Teachers) grid.Rows.Add(teacher.FullName, teacher.Login, teacher.TemporaryPassword ?? "—", diff --git a/BiStudent/Pages/SchedulePage.cs b/BiStudent/Pages/SchedulePage.cs index 5224fb3..e878c78 100644 --- a/BiStudent/Pages/SchedulePage.cs +++ b/BiStudent/Pages/SchedulePage.cs @@ -81,23 +81,32 @@ private void BuildDays() for (var index = 0; index < 6; index++) { var date = monday.AddDays(index); - var column = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(index == 0 ? 0 : 4, 0, index == 5 ? 0 : 4, 0), Padding = new Padding(10), Radius = 12 }; + var column = new SmoothPanel { Dock = DockStyle.Fill, Margin = new Padding(index == 0 ? 0 : 4, 0, index == 5 ? 0 : 4, 0), Padding = new Padding(8), Radius = 12 }; var header = new Panel { Dock = DockStyle.Top, Height = 54, BackColor = Theme.Surface }; var title = Ui.Label($"{UiFormat.DayLong(date.DayOfWeek)}\n{date:dd.MM}", 9.5f, date == DateOnly.FromDateTime(DateTime.Today) ? Theme.AccentHover : Theme.Text, FontStyle.Bold); title.TextAlign = ContentAlignment.MiddleCenter; header.Controls.Add(title); if (date == DateOnly.FromDateTime(DateTime.Today)) header.Controls.Add(new Panel { Dock = DockStyle.Bottom, Height = 2, BackColor = Theme.Accent }); var list = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoScroll = true, FlowDirection = FlowDirection.TopDown, WrapContents = false, BackColor = Theme.Surface, Padding = new Padding(0, 8, 0, 0) }; + // Горизонтальной прокрутки в колонке дня не бывает: карточки всегда по ширине колонки. + list.AutoScroll = false; list.HorizontalScroll.Maximum = 0; list.HorizontalScroll.Visible = false; list.AutoScroll = true; + void SizeCards() + { + var overflow = list.Controls.Cast().Sum(x => x.Height + x.Margin.Vertical) + list.Padding.Vertical > list.ClientSize.Height; + var width = Math.Max(1, list.ClientSize.Width - (overflow && !list.VerticalScroll.Visible ? SystemInformation.VerticalScrollBarWidth : 0) - 1); + foreach (Control item in list.Controls) item.Width = width - item.Margin.Horizontal; + } var entries = lessons.Where(x => x.Date == date).OrderBy(x => x.Slot.Start).ToList(); if (entries.Count == 0) { var empty = Ui.Label("Нет пар", 9.5f, Theme.Muted); empty.Dock = DockStyle.Top; empty.Height = 40; empty.TextAlign = ContentAlignment.MiddleCenter; list.Controls.Add(empty); } foreach (var lesson in entries) { - var card = new LessonCard(lesson) { Width = Math.Max(120, list.ClientSize.Width - 6) }; + var card = new LessonCard(lesson); card.Click += (_, _) => ShowMenu(card, lesson); list.Controls.Add(card); } - list.Resize += (_, _) => { foreach (var card in list.Controls.OfType()) card.Width = Math.Max(120, list.ClientSize.Width - 6); }; + list.Resize += (_, _) => SizeCards(); column.Controls.Add(list); column.Controls.Add(header); days.Controls.Add(column, index, 0); + SizeCards(); } days.ResumeLayout(); } @@ -138,11 +147,13 @@ private async Task OpenSlotAsync(SlotDto? slot, DateOnly date) private async Task ImportAsync() { - using var guide = new DialogShell("Импорт расписания", 680, 330); - guide.Content.Controls.Add(Ui.Label( + using var guide = new DialogShell("Импорт расписания", 720, 280); + var explanation = Ui.Label( "Excel — расписание колледжа на семестр (блоки «день · звонок · пара · аудитория · группа»). " + "Вы выберете группы, а учётные записи преподавателей создадутся автоматически (нужны права администратора).\n\n" + - "CSV: День;Начало;Конец;Предмет;Группа;Аудитория;Неделя — одна строка на шаблон пары.", 10, Theme.Text)); + "CSV: День;Начало;Конец;Предмет;Группа;Аудитория;Неделя — одна строка на шаблон пары.", 10, Theme.Text); + explanation.TextAlign = ContentAlignment.TopLeft; + guide.Content.Controls.Add(explanation); var template = new ModernButton { Text = "Сохранить шаблон…", Width = 180, Height = 40 }; template.Click += async (_, _) => { diff --git a/BiStudent/Pages/SlotDialog.cs b/BiStudent/Pages/SlotDialog.cs index 5aaaaeb..2f95167 100644 --- a/BiStudent/Pages/SlotDialog.cs +++ b/BiStudent/Pages/SlotDialog.cs @@ -41,6 +41,7 @@ public SlotDialog(ApiClient api, SlotDto? slot, DateOnly selectedDate, IEnumerab foreach (var height in new[] { 46, 0, 46, 46, 90, 46, 46, 42, 46, 46, 50 }) fields.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); Add(0, "Группа", group); fields.Controls.Add(newGroupLabel, 0, 1); newGroup.Dock = DockStyle.Fill; fields.Controls.Add(newGroup, 1, 1); Add(2, "Предмет", subject); Add(3, "Аудитория", room); Add(4, "Пресеты звонков", presets); Add(5, "Начало", start); Add(6, "Конец", end); Add(7, "", oneOff); + day.Dock = date.Dock = DockStyle.Fill; fields.Controls.Add(dateLabel, 0, 8); fields.Controls.Add(day, 1, 8); Add(9, "Неделя", parity); fields.Controls.Add(error, 0, 10); fields.SetColumnSpan(error, 2); diff --git a/BiStudent/Pages/TodayPage.cs b/BiStudent/Pages/TodayPage.cs index 4d589b2..1e5c018 100644 --- a/BiStudent/Pages/TodayPage.cs +++ b/BiStudent/Pages/TodayPage.cs @@ -32,7 +32,7 @@ public TodayPage(ApiClient api) : base(api, "Сегодня", UiFormat.LongDate( nowCard.Dock = DockStyle.Fill; nowCard.Margin = new Padding(0, 0, 14, 0); lessonsCard.Dock = DockStyle.Fill; lessonsCard.Margin = Padding.Empty; lessons.Columns.Add("Время", 80); lessons.Columns.Add("Занятие", 300); lessons.Columns.Add("Статус", 110); - lessons.Resize += (_, _) => lessons.Columns[1].Width = Math.Max(160, lessons.ClientSize.Width - 200); + lessons.Resize += (_, _) => lessons.Columns[1].Width = Math.Max(1, lessons.ClientSize.Width - lessons.Columns[0].Width - lessons.Columns[2].Width - SystemInformation.VerticalScrollBarWidth - 4); lessons.DoubleClick += async (_, _) => { if (lessons.SelectedItems.Count == 1 && lessons.SelectedItems[0].Tag is LessonDto lesson && Shell is { } shell) await shell.OpenLessonAsync(lesson); @@ -41,6 +41,7 @@ public TodayPage(ApiClient api) : base(api, "Сегодня", UiFormat.LongDate( middle.Controls.Add(nowCard, 0, 0); middle.Controls.Add(lessonsCard, 1, 0); riskCard.Dock = DockStyle.Bottom; riskCard.Height = 210; riskCard.Margin = Padding.Empty; + Resize += (_, _) => riskCard.Height = Height < 760 ? 186 : 210; riskCard.Body.Controls.Add(riskList); var spacer = new Panel { Dock = DockStyle.Bottom, Height = 16, BackColor = Theme.Background }; @@ -154,13 +155,16 @@ private void BuildNow(DashboardDto data, DateTime now, List? r foreach (var old in nowCard.Body.Controls.Cast().ToList()) old.Dispose(); nowCard.Body.Controls.Clear(); var layout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 6, BackColor = Theme.Surface }; - foreach (var height in new[] { 30, 46, 28, 26, 40, 60 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + // Строки по высоте содержимого, свободное место — над кнопками: карточка помещается и в окне 1180×720. + foreach (var height in new[] { 38, 26, 24, 32 }) layout.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 46)); var lesson = data.Current ?? data.Next; if (lesson is null) { nowCard.Caption = "Сегодня"; - layout.Controls.Add(Ui.Label("Занятий сегодня больше нет", 18, Theme.Text, FontStyle.Bold), 0, 1); - layout.Controls.Add(Ui.Label("Отметки прошедших занятий доступны в журнале.", 10, Theme.Muted), 0, 2); + layout.Controls.Add(Ui.Label("Занятий сегодня больше нет", 18, Theme.Text, FontStyle.Bold), 0, 0); + layout.Controls.Add(Ui.Label("Отметки прошедших занятий доступны в журнале.", 10, Theme.Muted), 0, 1); var journal = new ModernButton { Text = "Открыть журнал", Width = 180 }; journal.Click += async (_, _) => { if (Shell is { } shell) await shell.ShowPageAsync("journal"); }; var row = Ui.Row(); row.Controls.Add(journal); layout.Controls.Add(row, 0, 5); @@ -169,25 +173,28 @@ private void BuildNow(DashboardDto data, DateTime now, List? r } var live = data.Current is not null; nowCard.Caption = live ? "● Идёт сейчас" : "Следующее занятие"; - layout.Controls.Add(Ui.Label(lesson.Slot.Subject, 20, Theme.Text, FontStyle.Bold), 0, 1); - layout.Controls.Add(Ui.Label($"{lesson.Slot.GroupName} · {UiFormat.Range(lesson.Slot)}{(lesson.Slot.Room is { } room ? $" · ауд. {room}" : "")}", 10.5f, Theme.Muted), 0, 2); + var subject = Ui.Label(lesson.Slot.Subject, 18, Theme.Text, FontStyle.Bold); subject.AutoEllipsis = true; + layout.Controls.Add(subject, 0, 0); + layout.Controls.Add(Ui.Label($"{lesson.Slot.GroupName} · {UiFormat.Range(lesson.Slot)}{(lesson.Slot.Room is { } room ? $" · ауд. {room}" : "")}", 10.5f, Theme.Muted), 0, 1); if (live) { var progress = new ProgressLine { Dock = DockStyle.Fill, Margin = new Padding(0, 8, 0, 8), Value = (now - lesson.StartsAt).TotalMinutes / (lesson.EndsAt - lesson.StartsAt).TotalMinutes * 100 }; - layout.Controls.Add(progress, 0, 3); + layout.Controls.Add(progress, 0, 2); rows ??= []; var marked = rows.Count(r => r.Status is not null); - layout.Controls.Add(Ui.Label($"{marked} из {rows.Count} отмечено", 13, Theme.Text, FontStyle.Bold), 0, 4); + layout.Controls.Add(Ui.Label($"{marked} из {rows.Count} отмечено", 13, Theme.Text, FontStyle.Bold), 0, 3); } else { - layout.Controls.Add(Ui.Label($"Начнётся через {UiFormat.Duration((lesson.StartsAt - now).TotalMinutes)}", 10.5f, Theme.AccentHover), 0, 3); - layout.Controls.Add(Ui.Label("Камеру можно запустить заранее — отметка начнётся со звонком.", 9.5f, Theme.Muted), 0, 4); + layout.Controls.Add(Ui.Label($"Начнётся через {UiFormat.Duration((lesson.StartsAt - now).TotalMinutes)}", 10.5f, Theme.AccentHover), 0, 2); + layout.Controls.Add(Ui.Label("Камеру можно запустить заранее — отметка начнётся со звонком.", 9.5f, Theme.Muted), 0, 3); } var actions = Ui.Row(); - var camera = new ModernButton { Text = live ? "Начать отметку" : "Подготовить камеру", Icon = "◉", Primary = true, Width = 250 }; + var camera = new ModernButton { Text = live ? "Начать отметку" : "Подготовить камеру", Icon = "◉", Primary = true }; camera.Click += async (_, _) => { if (Shell is { } shell) await shell.OpenCamera(lesson); }; - var manual = new ModernButton { Text = "Открыть занятие", Width = 190 }; + var manual = new ModernButton { Text = "Открыть занятие" }; + camera.Width = TextRenderer.MeasureText(camera.Text, camera.Font).Width + 64; + manual.Width = TextRenderer.MeasureText(manual.Text, manual.Font).Width + 40; manual.Click += async (_, _) => { if (Shell is { } shell) await shell.OpenLessonAsync(lesson); }; actions.Controls.Add(camera); actions.Controls.Add(manual); layout.Controls.Add(actions, 0, 5);