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