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/.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/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/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/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 new file mode 100644 index 0000000..1b746fb --- /dev/null +++ b/BiStudent/LocalServer.cs @@ -0,0 +1,180 @@ +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://*: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) + { + // 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 + { + 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/MainForm.cs b/BiStudent/MainForm.cs index 0fcec4e..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); @@ -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; 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 0a44bbd..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(); } @@ -124,7 +133,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)); } @@ -137,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); 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/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/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/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" diff --git a/scripts/package.ps1 b/scripts/package.ps1 new file mode 100644 index 0000000..feb3f72 --- /dev/null +++ b/scripts/package.ps1 @@ -0,0 +1,40 @@ +<# +.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' +# Нативные библиотеки (OpenCV, SQLite) лежат рядом с exe: при самораспаковке первый запуск ждал распаковки ~200 МБ во временную папку. +# ReadyToRun компилирует код заранее, чтобы клиент и сервер не тратили секунды на JIT при каждом старте. +$single = @('-c', 'Release', '-r', 'win-x64', '--self-contained', 'true', '-p:PublishSingleFile=true', + '-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 +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)) МБ)" 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