From aa5407daebd0d73642821fdeaac22cf0fc072b33 Mon Sep 17 00:00:00 2001 From: ShaoHua <345265198@qqcom> Date: Sat, 4 Apr 2026 22:11:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20TodoList=20?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=EF=BC=8C=E6=96=B0=E5=A2=9E=E5=8A=A8=E6=80=81?= =?UTF-8?q?=20API=20=E4=B8=8E=20MAUI=20=E5=86=85=E5=B5=8C=20Web=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + .vscode/launch.json | 35 + .vscode/tasks.json | 41 ++ Publish.ps1 | 46 ++ README.md | 2 +- SCRIPTS_README.md | 10 +- TodoList.slnx | 3 +- TodoList/App.xaml.cs | 44 +- TodoList/TodoList.csproj | 1 + TodoList/ViewModels/MainViewModel.cs | 2 - TodoList/Views/MainWindow.xaml | 2 +- TodoList/Views/MainWindow.xaml.cs | 2 +- docs/实现对比文档.md | 2 +- restart-service.ps1 | 92 --- restart-web.ps1 | 101 +++ .../Controllers/TasksController.cs | 291 -------- src/TodoList.Api/Models/TaskModels.cs | 117 ---- .../Repositories/TaskRepository.cs | 122 ---- src/TodoList.Api/Services/TaskService.cs | 185 ----- src/TodoList.Api/TodoList.Api.http | 6 - .../Data/TodoDbContext.cs | 25 +- .../DynamicApi/DynamicApiExtensions.cs | 11 + .../DynamicApi/DynamicApiMiddleware.cs | 637 ++++++++++++++++++ .../DynamicApi/HttpAttributes.cs | 76 +++ .../DynamicApi/ParameterBindingAttributes.cs | 11 + .../DynamicApi/RemoteServiceAttribute.cs | 8 + .../Interfaces/IDynamicApiService.cs | 5 + .../Interfaces/ITaskService.cs | 17 + .../20260313044926_InitialCreate.Designer.cs | 8 +- .../20260313044926_InitialCreate.cs | 4 +- ...20260313092658_AddParentTaskId.Designer.cs | 14 +- .../20260313092658_AddParentTaskId.cs | 4 +- .../Migrations/TodoDbContextModelSnapshot.cs | 14 +- src/TodoList.Application/Models/TaskModels.cs | 47 ++ .../Properties/launchSettings.json | 12 + .../Repositories/TaskRepository.cs | 79 +++ .../ServiceCollectionExtensions.cs | 23 + .../Services/TaskService.cs | 127 ++++ .../TodoList.Application.csproj | 22 + src/TodoList.Core/Class1.cs | 6 - .../Entities/{Task.cs => TaskEntity.cs} | 6 +- .../Interfaces/ITaskRepository.cs | 20 +- src/TodoList.Core/Interfaces/ITaskService.cs | 73 -- src/TodoList.Core/TodoList.Core.csproj | 4 +- .../Program.cs | 30 +- .../Properties/launchSettings.json | 6 +- .../TodoList.Host.csproj} | 6 +- .../appsettings.Development.json | 0 .../appsettings.json | 0 .../todolist.db | Bin 32768 -> 32768 bytes src/TodoList.Maui/App.xaml.cs | 230 +++++-- src/TodoList.Maui/AppShell.xaml | 2 - src/TodoList.Maui/BuildSetup.ps1 | 34 +- src/TodoList.Maui/MauiProgram.cs | 83 ++- src/TodoList.Maui/Models/AppSettings.cs | 44 ++ .../Platforms/Windows/WindowsWindowService.cs | 42 +- src/TodoList.Maui/README.md | 2 +- .../Resources/Images/app_titlebar_icon.svg | 8 + .../Resources/Images/titlebar_icon.png | Bin 0 -> 106636 bytes src/TodoList.Maui/Services/AppMetadata.cs | 60 ++ .../Services/EmbeddedWebServerService.cs | 138 ++++ .../Services/HotKeySettingsService.cs | 48 +- .../Services/IEmbeddedWebServerService.cs | 9 + .../Platforms/WindowsGlobalHotKeyService.cs | 104 ++- .../Platforms/WindowsSystemTrayService.cs | 17 +- src/TodoList.Maui/TodoList.Maui.csproj | 100 ++- .../TodoList.Maui.csproj.DotSettings | 2 + .../ViewModels/QuickEntryViewModel.cs | 53 -- src/TodoList.Maui/Views/MainPage.xaml | 30 +- src/TodoList.Maui/Views/MainPage.xaml.cs | 63 +- src/TodoList.Maui/Views/QuickEntryPage.xaml | 46 -- .../Views/QuickEntryPage.xaml.cs | 22 - src/TodoList.Maui/appsettings.json | 16 + src/TodoList.Maui/icon.jpg | Bin 0 -> 50666 bytes src/TodoList.Maui/setup.iss | 6 +- src/TodoList.Web/src/App.vue | 103 ++- src/TodoList.Web/src/api/client.ts | 55 +- src/TodoList.Web/src/api/tasks.ts | 137 +++- .../src/components/HotKeySettingsDialog.vue | 4 +- .../src/components/TaskEditDialog.vue | 147 ++-- src/TodoList.Web/src/components/TaskItem.vue | 404 +++++++++-- src/TodoList.Web/src/components/TaskList.vue | 488 +++++++------- .../src/services/localStorageService.ts | 5 +- .../src/services/taskNormalizer.ts | 68 ++ src/TodoList.Web/src/style.css | 8 +- src/TodoList.Web/src/types/task.ts | 3 +- src/TodoList.Web/vite.config.ts | 19 +- start-forend.ps1 | 81 +++ start-maui.ps1 | 96 +++ start-service.ps1 | 128 ---- stop-service.ps1 | 97 --- 91 files changed, 3425 insertions(+), 1978 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 Publish.ps1 delete mode 100644 restart-service.ps1 create mode 100644 restart-web.ps1 delete mode 100644 src/TodoList.Api/Controllers/TasksController.cs delete mode 100644 src/TodoList.Api/Models/TaskModels.cs delete mode 100644 src/TodoList.Api/Repositories/TaskRepository.cs delete mode 100644 src/TodoList.Api/Services/TaskService.cs delete mode 100644 src/TodoList.Api/TodoList.Api.http rename src/{TodoList.Api => TodoList.Application}/Data/TodoDbContext.cs (54%) create mode 100644 src/TodoList.Application/DynamicApi/DynamicApiExtensions.cs create mode 100644 src/TodoList.Application/DynamicApi/DynamicApiMiddleware.cs create mode 100644 src/TodoList.Application/DynamicApi/HttpAttributes.cs create mode 100644 src/TodoList.Application/DynamicApi/ParameterBindingAttributes.cs create mode 100644 src/TodoList.Application/DynamicApi/RemoteServiceAttribute.cs create mode 100644 src/TodoList.Application/Interfaces/IDynamicApiService.cs create mode 100644 src/TodoList.Application/Interfaces/ITaskService.cs rename src/{TodoList.Api => TodoList.Application}/Migrations/20260313044926_InitialCreate.Designer.cs (91%) rename src/{TodoList.Api => TodoList.Application}/Migrations/20260313044926_InitialCreate.cs (96%) rename src/{TodoList.Api => TodoList.Application}/Migrations/20260313092658_AddParentTaskId.Designer.cs (85%) rename src/{TodoList.Api => TodoList.Application}/Migrations/20260313092658_AddParentTaskId.cs (93%) rename src/{TodoList.Api => TodoList.Application}/Migrations/TodoDbContextModelSnapshot.cs (85%) create mode 100644 src/TodoList.Application/Models/TaskModels.cs create mode 100644 src/TodoList.Application/Properties/launchSettings.json create mode 100644 src/TodoList.Application/Repositories/TaskRepository.cs create mode 100644 src/TodoList.Application/ServiceCollectionExtensions.cs create mode 100644 src/TodoList.Application/Services/TaskService.cs create mode 100644 src/TodoList.Application/TodoList.Application.csproj delete mode 100644 src/TodoList.Core/Class1.cs rename src/TodoList.Core/Entities/{Task.cs => TaskEntity.cs} (89%) delete mode 100644 src/TodoList.Core/Interfaces/ITaskService.cs rename src/{TodoList.Api => TodoList.Host}/Program.cs (52%) rename src/{TodoList.Api => TodoList.Host}/Properties/launchSettings.json (90%) rename src/{TodoList.Api/TodoList.Api.csproj => TodoList.Host/TodoList.Host.csproj} (69%) rename src/{TodoList.Api => TodoList.Host}/appsettings.Development.json (100%) rename src/{TodoList.Api => TodoList.Host}/appsettings.json (100%) rename src/{TodoList.Api => TodoList.Host}/todolist.db (97%) create mode 100644 src/TodoList.Maui/Models/AppSettings.cs create mode 100644 src/TodoList.Maui/Resources/Images/app_titlebar_icon.svg create mode 100644 src/TodoList.Maui/Resources/Images/titlebar_icon.png create mode 100644 src/TodoList.Maui/Services/AppMetadata.cs create mode 100644 src/TodoList.Maui/Services/EmbeddedWebServerService.cs create mode 100644 src/TodoList.Maui/Services/IEmbeddedWebServerService.cs create mode 100644 src/TodoList.Maui/TodoList.Maui.csproj.DotSettings delete mode 100644 src/TodoList.Maui/ViewModels/QuickEntryViewModel.cs delete mode 100644 src/TodoList.Maui/Views/QuickEntryPage.xaml delete mode 100644 src/TodoList.Maui/Views/QuickEntryPage.xaml.cs create mode 100644 src/TodoList.Maui/appsettings.json create mode 100644 src/TodoList.Maui/icon.jpg create mode 100644 src/TodoList.Web/src/services/taskNormalizer.ts create mode 100644 start-forend.ps1 create mode 100644 start-maui.ps1 delete mode 100644 start-service.ps1 delete mode 100644 stop-service.ps1 diff --git a/.gitignore b/.gitignore index 16c6ee3..9d61aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ bld/ .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ +src/TodoList.Maui/wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ @@ -363,3 +364,4 @@ MigrationBackup/ FodyWeavers.xsd /Setup/Output /TodoList/Output +/src/TodoList.Maui/Output diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0532146 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // 使用 IntelliSense 找出 C# 调试存在哪些属性 + // 将悬停用于现有属性的说明 + // 有关详细信息,请访问 https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md。 + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // 如果已更改目标框架,请确保更新程序路径。 + "program": "${workspaceFolder}/src/TodoList.Host/bin/Debug/net10.0/TodoList.Host.dll", + "args": [], + "cwd": "${workspaceFolder}/src/TodoList.Host", + "stopAtEntry": false, + // 启用在启动 ASP.NET Core 时启动 Web 浏览器。有关详细信息: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser + "serverReadyAction": { + "action": "openExternally", + "pattern": "\\bNow listening on:\\s+(https?://\\S+)" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach" + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..bcea2f5 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/TodoList.Host/TodoList.Host.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "publish", + "command": "dotnet", + "type": "process", + "args": [ + "publish", + "${workspaceFolder}/src/TodoList.Host/TodoList.Host.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary;ForceNoAlign" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "watch", + "command": "dotnet", + "type": "process", + "args": [ + "watch", + "run", + "--project", + "${workspaceFolder}/src/TodoList.Host/TodoList.Host.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/Publish.ps1 b/Publish.ps1 new file mode 100644 index 0000000..d63c8ee --- /dev/null +++ b/Publish.ps1 @@ -0,0 +1,46 @@ +$ErrorActionPreference = "Stop" + +$ScriptPath = $PSScriptRoot +$MauiProjectPath = "d:\Proj\TodoList\src\TodoList.Maui" +$Timestamp = Get-Date -Format "yyyy_MM_dd_HH_mm" + +$OutputBasePath = Join-Path $ScriptPath "PublishOutput" +$MauiPublishPath = Join-Path $OutputBasePath "TodoList.Maui" +$ZipFileName = "TodoList_Publish_$Timestamp.zip" +$ZipFilePath = Join-Path $ScriptPath $ZipFileName + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "TodoList Publish Script" -ForegroundColor Cyan +Write-Host "Timestamp: $Timestamp" -ForegroundColor Cyan +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "" + +if (Test-Path $OutputBasePath) { + Write-Host "Cleaning previous publish output..." -ForegroundColor Yellow + Remove-Item $OutputBasePath -Recurse -Force +} +New-Item -ItemType Directory -Path $OutputBasePath -Force | Out-Null + +Write-Host "[Step 1/2] Publishing TodoList.Maui..." -ForegroundColor Cyan +$MauiProjectFile = Join-Path $MauiProjectPath "TodoList.Maui.csproj" +dotnet publish $MauiProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false -o $MauiPublishPath +if ($LASTEXITCODE -ne 0) { + Write-Error "MAUI publish failed" + exit 1 +} +Write-Host "TodoList.Maui publish completed successfully!" -ForegroundColor Green +Write-Host "" + +Write-Host "[Step 2/2] Creating ZIP package..." -ForegroundColor Cyan +if (Test-Path $ZipFilePath) { + Write-Host "Removing existing ZIP file..." -ForegroundColor Gray + Remove-Item $ZipFilePath -Force +} +Compress-Archive -Path "$MauiPublishPath\*" -DestinationPath $ZipFilePath -Force +Write-Host "ZIP package created: $ZipFilePath" -ForegroundColor Green +Write-Host "" + +Write-Host "========================================" -ForegroundColor Cyan +Write-Host "Publish completed successfully!" -ForegroundColor Green +Write-Host "Output: $ZipFilePath" -ForegroundColor Green +Write-Host "========================================" -ForegroundColor Cyan diff --git a/README.md b/README.md index cb18958..22738db 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ dotnet restore dotnet ef database update dotnet run ``` -API 将在 `http://localhost:5057` 启动 +API 将在 `http://localhost:5173` 启动 #### 3. 启动前端 Web ```bash diff --git a/SCRIPTS_README.md b/SCRIPTS_README.md index c78d47e..2364642 100644 --- a/SCRIPTS_README.md +++ b/SCRIPTS_README.md @@ -36,8 +36,8 @@ 🚀 启动服务... ✅ TodoList.Api 服务已启动 进程 ID: 65992 - 访问地址: http://localhost:5057 - Swagger 文档: http://localhost:5057/swagger + 访问地址: http://localhost:5173 + Swagger 文档: http://localhost:5173/swagger [3/3] 启动 TodoList.Maui 应用... 🚀 启动 TodoList.Maui... @@ -208,8 +208,8 @@ Setup package created successfully! # 启动 API 服务和 MAUI 应用(默认) .\start-service.ps1 -# 访问 http://localhost:5057 查看服务 -# 访问 http://localhost:5057/swagger 查看 API 文档 +# 访问 http://localhost:5173 查看服务 +# 访问 http://localhost:5173/swagger 查看 API 文档 ``` ### 场景 2: 只启动 API 服务 @@ -273,7 +273,7 @@ Setup package created successfully! 3. **端口占用** - 如果端口 5057 被占用,启动会失败 - - 使用 `netstat -ano | findstr :5057` 检查端口占用情况 + - 使用 `netstat -ano | findstr :5173` 检查端口占用情况 4. **MAUI 应用构建** - 如果 MAUI 应用不存在,需要先构建:`dotnet build src\TodoList.Maui\TodoList.Maui.csproj` diff --git a/TodoList.slnx b/TodoList.slnx index e12c6c2..e6f9e0e 100644 --- a/TodoList.slnx +++ b/TodoList.slnx @@ -6,7 +6,8 @@ - + + \ No newline at end of file diff --git a/TodoList/App.xaml.cs b/TodoList/App.xaml.cs index df48065..033b4e6 100644 --- a/TodoList/App.xaml.cs +++ b/TodoList/App.xaml.cs @@ -7,17 +7,19 @@ using TodoList.Services; using TodoList.ViewModels; using TodoList.Views; using System.Linq; +using System.Reflection; namespace TodoList { public partial class App : System.Windows.Application { + private const string MainWindowTitle = "待办事项"; private IDataService _dataService; private GlobalShortcutService _shortcutService; private MainWindow _mainWindow; private QuickEntryWindow? _quickEntryWindow; private SettingsService _settingsService; - private System.Windows.Forms.NotifyIcon _notifyIcon; + private NotifyIcon _notifyIcon; private Mutex _mutex; private EventWaitHandle _eventWaitHandle; private const string UniqueEventName = "Global\\TodoListApp_Event_v1"; @@ -60,7 +62,7 @@ namespace TodoList catch { // Fallback to old method if event open fails - var hWnd = FindWindow(null, "待办事项"); + var hWnd = FindWindow(null, MainWindowTitle); if (hWnd != IntPtr.Zero) { ShowWindow(hWnd, 9); // SW_RESTORE @@ -108,6 +110,7 @@ namespace TodoList var mainViewModel = new MainViewModel(_dataService, _settingsService); _mainWindow = new MainWindow(mainViewModel); + _mainWindow.Title = MainWindowTitle; _mainWindow.Loaded += MainWindow_Loaded; // Initialize Tray Icon @@ -132,7 +135,7 @@ namespace TodoList { try { - var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName; + var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; string cmd = $"\"{exePath}\" --silent"; // If running as dotnet tool, try to find the shim or stable entry point @@ -168,7 +171,7 @@ namespace TodoList private void InitializeTrayIcon() { - _notifyIcon = new System.Windows.Forms.NotifyIcon(); + _notifyIcon = new NotifyIcon(); // Try load icon from resource or file try @@ -195,15 +198,43 @@ namespace TodoList } _notifyIcon.Visible = true; - _notifyIcon.Text = "TodoList"; + _notifyIcon.Text = GetNotifyIconText(); _notifyIcon.DoubleClick += (s, e) => ShowMainWindow(); - var contextMenu = new System.Windows.Forms.ContextMenuStrip(); + var contextMenu = new ContextMenuStrip(); contextMenu.Items.Add("打开主界面", null, (s, e) => ShowMainWindow()); contextMenu.Items.Add("退出", null, (s, e) => ExitApplication()); _notifyIcon.ContextMenuStrip = contextMenu; } + private static string GetNotifyIconText() + { + var version = GetDisplayVersion(); + var text = string.IsNullOrWhiteSpace(version) ? MainWindowTitle : $"{MainWindowTitle} v{version}"; + + return text.Length > 63 ? text[..63] : text; + } + + private static string? GetDisplayVersion() + { + var asm = Assembly.GetExecutingAssembly(); + + var info = asm.GetCustomAttribute()?.InformationalVersion?.Trim(); + if (!string.IsNullOrWhiteSpace(info)) + { + var plus = info.IndexOf('+'); + return plus >= 0 ? info[..plus] : info; + } + + var file = asm.GetCustomAttribute()?.Version?.Trim(); + if (!string.IsNullOrWhiteSpace(file)) + { + return file; + } + + return asm.GetName().Version?.ToString(); + } + private void ShowMainWindow() { Log("ShowMainWindow called"); @@ -298,6 +329,7 @@ namespace TodoList if (_quickEntryWindow == null) { _quickEntryWindow = new QuickEntryWindow(_dataService); + _quickEntryWindow.Title = "新建待办"; } if (_quickEntryWindow.WindowState == WindowState.Minimized) diff --git a/TodoList/TodoList.csproj b/TodoList/TodoList.csproj index ceb6fe5..f8328a4 100644 --- a/TodoList/TodoList.csproj +++ b/TodoList/TodoList.csproj @@ -5,6 +5,7 @@ net8.0-windows enable enable + 65001 true true icon.ico diff --git a/TodoList/ViewModels/MainViewModel.cs b/TodoList/ViewModels/MainViewModel.cs index c0afc50..83c7454 100644 --- a/TodoList/ViewModels/MainViewModel.cs +++ b/TodoList/ViewModels/MainViewModel.cs @@ -55,8 +55,6 @@ namespace TodoList.ViewModels [ObservableProperty] private Models.SortOrder sortOrder = Models.SortOrder.Descending; - public string AppVersion => System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0"; - public string FullShortcut { get diff --git a/TodoList/Views/MainWindow.xaml b/TodoList/Views/MainWindow.xaml index 58a45d6..1fd19d8 100644 --- a/TodoList/Views/MainWindow.xaml +++ b/TodoList/Views/MainWindow.xaml @@ -114,7 +114,7 @@ - diff --git a/TodoList/Views/MainWindow.xaml.cs b/TodoList/Views/MainWindow.xaml.cs index 90ad15c..adef138 100644 --- a/TodoList/Views/MainWindow.xaml.cs +++ b/TodoList/Views/MainWindow.xaml.cs @@ -28,7 +28,7 @@ namespace TodoList.Views // If settings are open, close settings? // But user requirement is "Equals pressing X button", which usually means Close/Hide window. // However, if we want better UX: - if (DataContext is MainViewModel vm && vm.IsSettingsOpen) + if (DataContext is MainViewModel { IsSettingsOpen: true } vm) { vm.IsSettingsOpen = false; e.Handled = true; diff --git a/docs/实现对比文档.md b/docs/实现对比文档.md index 9992e8b..9d7cd78 100644 --- a/docs/实现对比文档.md +++ b/docs/实现对比文档.md @@ -182,7 +182,7 @@ ## 当前运行状态 ### 服务状态 -- ✅ **TodoList.Api**: 运行中 (http://localhost:5057) +- ✅ **TodoList.Api**: 运行中 (http://localhost:5173) - ✅ **TodoList.Web**: 运行中 (http://localhost:5173) - ✅ **TodoList.Maui**: 运行中 (Windows 桌面应用) diff --git a/restart-service.ps1 b/restart-service.ps1 deleted file mode 100644 index 7b03cc9..0000000 --- a/restart-service.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -param( - [switch]$StartMaui = $false, - [switch]$Force = $false -) - -$ErrorActionPreference = "Stop" - -Write-Host "====================================" -ForegroundColor Cyan -Write-Host " TodoList 服务重启脚本" -ForegroundColor Cyan -Write-Host "====================================" -ForegroundColor Cyan -Write-Host "" - -Write-Host "[1/3] 停止现有服务..." -ForegroundColor Yellow - -$stopScriptPath = Join-Path $PSScriptRoot "stop-service.ps1" - -if (!(Test-Path $stopScriptPath)) { - Write-Host "❌ 停止脚本不存在: $stopScriptPath" -ForegroundColor Red - exit 1 -} - -try { - if ($Force) { - & $stopScriptPath -Force - } else { - & $stopScriptPath - } - Write-Host "✅ 服务已停止" -ForegroundColor Green -} catch { - Write-Host "❌ 停止服务失败: $_" -ForegroundColor Red - exit 1 -} - -Write-Host "" -Write-Host "[2/3] 等待进程完全关闭..." -ForegroundColor Yellow - -$timeout = 10 -$elapsed = 0 - -while ($elapsed -lt $timeout) { - $apiRunning = Get-Process -Name "dotnet" -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like "*TodoList.Api*" } - $mauiRunning = Get-Process -Name "TodoList.Maui" -ErrorAction SilentlyContinue - - if (-not $apiRunning -and -not $mauiRunning) { - Write-Host "✅ 所有进程已关闭" -ForegroundColor Green - break - } - - Start-Sleep -Seconds 1 - $elapsed++ - - if ($elapsed -lt $timeout) { - Write-Host " 等待中... ($elapsed/$timeout 秒)" -ForegroundColor Gray - } -} - -if ($elapsed -ge $timeout) { - Write-Host "⚠️ 等待超时,继续启动..." -ForegroundColor Yellow -} - -Write-Host "" -Write-Host "[3/3] 启动服务..." -ForegroundColor Yellow - -$startScriptPath = Join-Path $PSScriptRoot "start-service.ps1" - -if (!(Test-Path $startScriptPath)) { - Write-Host "❌ 启动脚本不存在: $startScriptPath" -ForegroundColor Red - exit 1 -} - -try { - if ($StartMaui) { - & $startScriptPath -StartMaui - } else { - & $startScriptPath - } - Write-Host "✅ 服务已启动" -ForegroundColor Green -} catch { - Write-Host "❌ 启动服务失败: $_" -ForegroundColor Red - exit 1 -} - -Write-Host "" -Write-Host "====================================" -ForegroundColor Cyan -Write-Host " 重启完成" -ForegroundColor Green -Write-Host "====================================" -ForegroundColor Cyan -Write-Host "" -Write-Host "💡 提示:" -ForegroundColor Yellow -Write-Host " - 按 Ctrl+C 可以停止服务" -ForegroundColor Gray -Write-Host " - 运行 stop-service.bat 可以关闭服务" -ForegroundColor Gray -Write-Host " - 运行 restart-service.bat 可以重启服务" -ForegroundColor Gray -Write-Host "" \ No newline at end of file diff --git a/restart-web.ps1 b/restart-web.ps1 new file mode 100644 index 0000000..93c7e3b --- /dev/null +++ b/restart-web.ps1 @@ -0,0 +1,101 @@ +param( + [switch]$Force = $false +) + +$ErrorActionPreference = "Stop" + +Write-Host "====================================" -ForegroundColor Cyan +Write-Host " TodoList.Web Restart Script" -ForegroundColor Cyan +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" + +$webProjectPath = Join-Path $PSScriptRoot "src\TodoList.Web" + +if (!(Test-Path $webProjectPath)) { + Write-Host "ERROR: Web project path not found: $webProjectPath" -ForegroundColor Red + exit 1 +} + +function Get-TodoListWebProcesses { + param( + [Parameter(Mandatory = $true)] + [string]$WebProjectPath + ) + + Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { + $_.CommandLine -and + $_.CommandLine -like "*vite*" -and + $_.CommandLine -like "*$WebProjectPath*" + } +} + +Write-Host "[1/3] Stopping existing service..." -ForegroundColor Yellow + +$webProcesses = @(Get-TodoListWebProcesses -WebProjectPath $webProjectPath) + +if ($webProcesses.Count -eq 0) { + Write-Host "OK: TodoList.Web is not running" -ForegroundColor Green +} else { + Write-Host "Found $($webProcesses.Count) process(es)" -ForegroundColor Yellow + foreach ($process in $webProcesses) { + $processId = $process.ProcessId + Write-Host "Stopping PID: $processId" -ForegroundColor Gray + try { + Stop-Process -Id $processId -Force:$Force -ErrorAction Stop + Write-Host "OK: Stopped PID: $processId" -ForegroundColor Green + } catch { + Write-Host "WARN: Failed to stop PID: $processId. $_" -ForegroundColor Yellow + } + } +} + +Write-Host "" +Write-Host "[2/3] Waiting for processes to exit..." -ForegroundColor Yellow + +$timeout = 10 +$elapsed = 0 + +while ($elapsed -lt $timeout) { + $webRunning = @(Get-TodoListWebProcesses -WebProjectPath $webProjectPath) + + if ($webRunning.Count -eq 0) { + Write-Host "OK: All processes exited" -ForegroundColor Green + break + } + + Start-Sleep -Seconds 1 + $elapsed++ + + if ($elapsed -lt $timeout) { + Write-Host "Waiting... ($elapsed/$timeout sec)" -ForegroundColor Gray + } +} + +if ($elapsed -ge $timeout) { + Write-Host "WARN: Timeout reached, continuing to start..." -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "[3/3] Starting service..." -ForegroundColor Yellow + +try { + Write-Host "Working directory: $webProjectPath" -ForegroundColor Gray + Write-Host "Running: npm run dev" -ForegroundColor Green + + $webProcess = Start-Process -FilePath "npm.cmd" -ArgumentList @("run", "dev") -WorkingDirectory $webProjectPath -PassThru + + Write-Host "OK: Started TodoList.Web" -ForegroundColor Green + Write-Host "PID: $($webProcess.Id)" -ForegroundColor Gray +} catch { + Write-Host "ERROR: Failed to start service. $_" -ForegroundColor Red + exit 1 +} + +Write-Host "" +Write-Host "====================================" -ForegroundColor Cyan +Write-Host " Done" -ForegroundColor Green +Write-Host "====================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Notes:" -ForegroundColor Yellow +Write-Host " - Vite will choose an available port automatically (no explicit port required)" -ForegroundColor Gray +Write-Host " - Check the npm/vite output for the Local URL" -ForegroundColor Gray diff --git a/src/TodoList.Api/Controllers/TasksController.cs b/src/TodoList.Api/Controllers/TasksController.cs deleted file mode 100644 index 3f09eea..0000000 --- a/src/TodoList.Api/Controllers/TasksController.cs +++ /dev/null @@ -1,291 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using TodoList.Api.Models; -using TodoTask = TodoList.Core.Entities.Task; -using TodoList.Core.Entities; -using TodoList.Core.Interfaces; - -namespace TodoList.Api.Controllers; - -/// -/// 任务控制器,提供任务的 RESTful API 接口 -/// -[ApiController] -[Route("api/[controller]")] -public class TasksController : ControllerBase -{ - private readonly ITaskService _taskService; - - /// - /// 构造函数,注入任务服务 - /// - /// 任务服务接口 - public TasksController(ITaskService taskService) - { - _taskService = taskService; - } - - /// - /// 获取任务列表 - /// - /// 可选参数,true 获取已完成任务,false 获取未完成任务,不传则获取所有任务 - /// 任务列表响应 - [HttpGet] - public async Task>>> GetTasks([FromQuery] bool? completed = null) - { - try - { - List tasks; - - if (completed.HasValue) - { - tasks = completed.Value - ? await _taskService.GetCompletedTasksAsync() - : await _taskService.GetActiveTasksAsync(); - } - else - { - tasks = await _taskService.GetAllTasksAsync(); - } - - var taskDtos = tasks.Select(MapToDto).ToList(); - - return Ok(new ApiResponse> - { - Success = true, - Data = taskDtos, - Message = "获取任务列表成功" - }); - } - catch (Exception ex) - { - return StatusCode(500, new ApiResponse> - { - Success = false, - Message = "获取任务列表失败", - Errors = new List { ex.Message } - }); - } - } - - /// - /// 根据ID获取指定任务 - /// - /// 任务ID - /// 任务详情响应 - [HttpGet("{id}")] - public async Task>> GetTask(int id) - { - try - { - var task = await _taskService.GetTaskByIdAsync(id); - if (task == null) - { - return NotFound(new ApiResponse - { - Success = false, - Message = $"未找到ID为 {id} 的任务" - }); - } - - return Ok(new ApiResponse - { - Success = true, - Data = MapToDto(task), - Message = "获取任务成功" - }); - } - catch (Exception ex) - { - return StatusCode(500, new ApiResponse - { - Success = false, - Message = "获取任务失败", - Errors = new List { ex.Message } - }); - } - } - - /// - /// 创建新任务 - /// - /// 创建任务的数据传输对象 - /// 创建的任务响应 - [HttpPost] - public async Task>> CreateTask([FromBody] CreateTaskDto dto) - { - try - { - if (string.IsNullOrWhiteSpace(dto.Title)) - { - return BadRequest(new ApiResponse - { - Success = false, - Message = "任务标题不能为空", - Errors = new List { "Title is required" } - }); - } - - var task = await _taskService.CreateTaskAsync(dto.Title, dto.Priority, dto.ParentTaskId); - - return CreatedAtAction(nameof(GetTask), new { id = task.Id }, new ApiResponse - { - Success = true, - Data = MapToDto(task), - Message = "创建任务成功" - }); - } - catch (Exception ex) - { - return StatusCode(500, new ApiResponse - { - Success = false, - Message = "创建任务失败", - Errors = new List { ex.Message } - }); - } - } - - /// - /// 更新任务 - /// - /// 任务ID - /// 更新任务的数据传输对象 - /// 更新后的任务响应 - [HttpPut("{id}")] - public async Task>> UpdateTask(int id, [FromBody] UpdateTaskDto dto) - { - try - { - var task = await _taskService.UpdateTaskAsync(id, dto.Title, dto.Priority); - - return Ok(new ApiResponse - { - Success = true, - Data = MapToDto(task), - Message = "更新任务成功" - }); - } - catch (KeyNotFoundException) - { - return NotFound(new ApiResponse - { - Success = false, - Message = $"未找到ID为 {id} 的任务" - }); - } - catch (Exception ex) - { - return StatusCode(500, new ApiResponse - { - Success = false, - Message = "更新任务失败", - Errors = new List { ex.Message } - }); - } - } - - /// - /// 切换任务的完成状态 - /// - /// 任务ID - /// 更新后的任务响应 - [HttpPatch("{id}/complete")] - public async Task>> ToggleComplete(int id) - { - try - { - var task = await _taskService.ToggleCompleteAsync(id); - - return Ok(new ApiResponse - { - Success = true, - Data = MapToDto(task), - Message = task.IsCompleted ? "任务已完成" : "任务已取消完成" - }); - } - catch (KeyNotFoundException) - { - return NotFound(new ApiResponse - { - Success = false, - Message = $"未找到ID为 {id} 的任务" - }); - } - catch (Exception ex) - { - return StatusCode(500, new ApiResponse - { - Success = false, - Message = "更新任务状态失败", - Errors = new List { ex.Message } - }); - } - } - - /// - /// 删除任务 - /// - /// 任务ID - /// 删除结果响应 - [HttpDelete("{id}")] - public async Task>> DeleteTask(int id) - { - try - { - await _taskService.DeleteTaskAsync(id); - - return Ok(new ApiResponse - { - Success = true, - Message = "删除任务成功" - }); - } - catch (KeyNotFoundException) - { - return NotFound(new ApiResponse - { - Success = false, - Message = $"未找到ID为 {id} 的任务" - }); - } - catch (Exception ex) - { - return StatusCode(500, new ApiResponse - { - Success = false, - Message = "删除任务失败", - Errors = new List { ex.Message } - }); - } - } - - /// - /// 将任务实体映射为数据传输对象 - /// - /// 任务实体 - /// 任务数据传输对象 - private static TaskDto MapToDto(TodoTask task) - { - return new TaskDto - { - Id = task.Id, - Title = task.Title, - Priority = task.Priority, - IsCompleted = task.IsCompleted, - CreatedAt = task.CreatedAt, - UpdatedAt = task.UpdatedAt, - ParentTaskId = task.ParentTaskId, - SubTasks = task.SubTasks?.Select(st => new TaskDto - { - Id = st.Id, - Title = st.Title, - Priority = st.Priority, - IsCompleted = st.IsCompleted, - CreatedAt = st.CreatedAt, - UpdatedAt = st.UpdatedAt, - ParentTaskId = st.ParentTaskId, - SubTasks = new List() - }).ToList() ?? new List() - }; - } -} diff --git a/src/TodoList.Api/Models/TaskModels.cs b/src/TodoList.Api/Models/TaskModels.cs deleted file mode 100644 index 7a318c5..0000000 --- a/src/TodoList.Api/Models/TaskModels.cs +++ /dev/null @@ -1,117 +0,0 @@ -using TodoList.Core.Entities; -using System.Text.Json.Serialization; - -namespace TodoList.Api.Models; - -/// -/// 创建任务的数据传输对象 -/// -public class CreateTaskDto -{ - /// - /// 任务标题 - /// - public string Title { get; set; } = string.Empty; - - /// - /// 任务优先级 - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public TaskPriority Priority { get; set; } = TaskPriority.Medium; - - /// - /// 父任务ID(可选) - /// - public int? ParentTaskId { get; set; } -} - -/// -/// 更新任务的数据传输对象 -/// -public class UpdateTaskDto -{ - /// - /// 新的任务标题(可选) - /// - public string? Title { get; set; } - - /// - /// 新的任务优先级(可选) - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public TaskPriority? Priority { get; set; } -} - -/// -/// 任务数据传输对象 -/// -public class TaskDto -{ - /// - /// 任务ID - /// - public int Id { get; set; } - - /// - /// 任务标题 - /// - public string Title { get; set; } = string.Empty; - - /// - /// 任务优先级 - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public TaskPriority Priority { get; set; } - - /// - /// 任务是否已完成 - /// - public bool IsCompleted { get; set; } - - /// - /// 任务创建时间 - /// - public DateTime CreatedAt { get; set; } - - /// - /// 任务最后更新时间 - /// - public DateTime UpdatedAt { get; set; } - - /// - /// 父任务ID - /// - public int? ParentTaskId { get; set; } - - /// - /// 子任务列表 - /// - public List SubTasks { get; set; } = new(); -} - -/// -/// API统一响应格式 -/// -/// 响应数据类型 -public class ApiResponse -{ - /// - /// 请求是否成功 - /// - public bool Success { get; set; } - - /// - /// 响应数据 - /// - public T? Data { get; set; } - - /// - /// 响应消息 - /// - public string Message { get; set; } = string.Empty; - - /// - /// 错误信息列表 - /// - public List Errors { get; set; } = new(); -} diff --git a/src/TodoList.Api/Repositories/TaskRepository.cs b/src/TodoList.Api/Repositories/TaskRepository.cs deleted file mode 100644 index e6ce7af..0000000 --- a/src/TodoList.Api/Repositories/TaskRepository.cs +++ /dev/null @@ -1,122 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using TodoList.Api.Data; -using TodoTask = TodoList.Core.Entities.Task; -using TodoList.Core.Interfaces; - -namespace TodoList.Api.Repositories; - -/// -/// 任务仓储实现类,使用 Entity Framework Core 进行数据访问 -/// -public class TaskRepository : ITaskRepository -{ - private readonly TodoDbContext _context; - - /// - /// 构造函数,注入数据库上下文 - /// - /// 数据库上下文 - public TaskRepository(TodoDbContext context) - { - _context = context; - } - - /// - /// 获取所有任务 - /// - /// 任务列表 - public async System.Threading.Tasks.Task> GetAllAsync() - { - return await _context.Tasks - .Include(t => t.SubTasks) - .ToListAsync(); - } - - /// - /// 根据ID获取指定任务 - /// - /// 任务ID - /// 任务对象,如果不存在则返回null - public async System.Threading.Tasks.Task GetByIdAsync(int id) - { - return await _context.Tasks - .Include(t => t.SubTasks) - .FirstOrDefaultAsync(t => t.Id == id); - } - - /// - /// 获取所有未完成的任务 - /// - /// 未完成任务列表,按创建时间降序排列 - public async System.Threading.Tasks.Task> GetActiveTasksAsync() - { - return await _context.Tasks - .Where(t => !t.IsCompleted) - .OrderByDescending(t => t.CreatedAt) - .ToListAsync(); - } - - /// - /// 获取所有已完成的任务 - /// - /// 已完成任务列表,按更新时间降序排列 - public async System.Threading.Tasks.Task> GetCompletedTasksAsync() - { - return await _context.Tasks - .Where(t => t.IsCompleted) - .OrderByDescending(t => t.UpdatedAt) - .ToListAsync(); - } - - /// - /// 添加新任务 - /// - /// 要添加的任务对象 - /// 添加后的任务对象(包含生成的ID) - public async System.Threading.Tasks.Task AddAsync(TodoTask task) - { - _context.Tasks.Add(task); - await _context.SaveChangesAsync(); - return task; - } - - /// - /// 更新任务 - /// - /// 要更新的任务对象 - /// 更新后的任务对象 - public async System.Threading.Tasks.Task UpdateAsync(TodoTask task) - { - task.UpdatedAt = DateTime.UtcNow; - _context.Tasks.Update(task); - await _context.SaveChangesAsync(); - return task; - } - - /// - /// 删除指定ID的任务 - /// - /// 任务ID - public async System.Threading.Tasks.Task DeleteAsync(int id) - { - var task = await _context.Tasks.FindAsync(id); - if (task != null) - { - _context.Tasks.Remove(task); - await _context.SaveChangesAsync(); - } - } - - /// - /// 获取指定父任务的所有子任务 - /// - /// 父任务ID - /// 子任务列表,按创建时间降序排列 - public async System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId) - { - return await _context.Tasks - .Where(t => t.ParentTaskId == parentTaskId) - .OrderByDescending(t => t.CreatedAt) - .ToListAsync(); - } -} diff --git a/src/TodoList.Api/Services/TaskService.cs b/src/TodoList.Api/Services/TaskService.cs deleted file mode 100644 index 7c769ee..0000000 --- a/src/TodoList.Api/Services/TaskService.cs +++ /dev/null @@ -1,185 +0,0 @@ -using TodoTask = TodoList.Core.Entities.Task; -using TodoList.Core.Entities; -using TodoList.Core.Interfaces; - -namespace TodoList.Api.Services; - -/// -/// 任务服务实现类,提供任务相关的业务逻辑 -/// -public class TaskService : ITaskService -{ - private readonly ITaskRepository _repository; - - /// - /// 构造函数,注入任务仓储 - /// - /// 任务仓储接口 - public TaskService(ITaskRepository repository) - { - _repository = repository; - } - - /// - /// 获取所有任务 - /// - /// 任务列表 - public async System.Threading.Tasks.Task> GetAllTasksAsync() - { - return await _repository.GetAllAsync(); - } - - /// - /// 根据ID获取指定任务 - /// - /// 任务ID - /// 任务对象,如果不存在则返回null - public async System.Threading.Tasks.Task GetTaskByIdAsync(int id) - { - return await _repository.GetByIdAsync(id); - } - - /// - /// 获取所有未完成的任务 - /// - /// 未完成任务列表 - public async System.Threading.Tasks.Task> GetActiveTasksAsync() - { - return await _repository.GetActiveTasksAsync(); - } - - /// - /// 获取所有已完成的任务 - /// - /// 已完成任务列表 - public async System.Threading.Tasks.Task> GetCompletedTasksAsync() - { - return await _repository.GetCompletedTasksAsync(); - } - - /// - /// 创建新任务 - /// - /// 任务标题 - /// 任务优先级 - /// 父任务ID(可选) - /// 创建的任务对象 - /// 当任务标题为空时抛出 - public async System.Threading.Tasks.Task CreateTaskAsync(string title, TaskPriority priority, int? parentTaskId = null) - { - if (string.IsNullOrWhiteSpace(title)) - { - throw new ArgumentException("任务标题不能为空", nameof(title)); - } - - var task = new TodoTask - { - Title = title.Trim(), - Priority = priority, - IsCompleted = false, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - ParentTaskId = parentTaskId - }; - - return await _repository.AddAsync(task); - } - - /// - /// 更新任务 - /// - /// 任务ID - /// 新的任务标题(可选) - /// 新的任务优先级(可选) - /// 更新后的任务对象 - /// 当任务不存在时抛出 - public async System.Threading.Tasks.Task UpdateTaskAsync(int id, string? title = null, TaskPriority? priority = null) - { - var task = await _repository.GetByIdAsync(id); - if (task == null) - { - throw new KeyNotFoundException($"未找到ID为 {id} 的任务"); - } - - if (!string.IsNullOrWhiteSpace(title)) - { - task.Title = title.Trim(); - } - - if (priority.HasValue) - { - task.Priority = priority.Value; - } - - return await _repository.UpdateAsync(task); - } - - /// - /// 切换任务的完成状态 - /// - /// 任务ID - /// 更新后的任务对象 - /// 当任务不存在时抛出 - public async System.Threading.Tasks.Task ToggleCompleteAsync(int id) - { - var task = await _repository.GetByIdAsync(id); - if (task == null) - { - throw new KeyNotFoundException($"未找到ID为 {id} 的任务"); - } - - task.IsCompleted = !task.IsCompleted; - var updatedTask = await _repository.UpdateAsync(task); - - if (task.IsCompleted) - { - await MarkSubTasksCompletedAsync(id); - } - - return updatedTask; - } - - /// - /// 递归标记所有子任务为已完成 - /// - /// 父任务ID - private async System.Threading.Tasks.Task MarkSubTasksCompletedAsync(int parentTaskId) - { - var subTasks = await _repository.GetSubTasksAsync(parentTaskId); - foreach (var subTask in subTasks) - { - if (!subTask.IsCompleted) - { - subTask.IsCompleted = true; - await _repository.UpdateAsync(subTask); - await MarkSubTasksCompletedAsync(subTask.Id); - } - } - } - - /// - /// 删除指定ID的任务 - /// - /// 任务ID - /// 当任务不存在时抛出 - public async System.Threading.Tasks.Task DeleteTaskAsync(int id) - { - var task = await _repository.GetByIdAsync(id); - if (task == null) - { - throw new KeyNotFoundException($"未找到ID为 {id} 的任务"); - } - - await _repository.DeleteAsync(id); - } - - /// - /// 获取指定父任务的所有子任务 - /// - /// 父任务ID - /// 子任务列表 - public async System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId) - { - return await _repository.GetSubTasksAsync(parentTaskId); - } -} diff --git a/src/TodoList.Api/TodoList.Api.http b/src/TodoList.Api/TodoList.Api.http deleted file mode 100644 index aa410e9..0000000 --- a/src/TodoList.Api/TodoList.Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@TodoList.Api_HostAddress = http://localhost:5057 - -GET {{TodoList.Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/TodoList.Api/Data/TodoDbContext.cs b/src/TodoList.Application/Data/TodoDbContext.cs similarity index 54% rename from src/TodoList.Api/Data/TodoDbContext.cs rename to src/TodoList.Application/Data/TodoDbContext.cs index f4f7f7b..9503c59 100644 --- a/src/TodoList.Api/Data/TodoDbContext.cs +++ b/src/TodoList.Application/Data/TodoDbContext.cs @@ -1,39 +1,26 @@ using Microsoft.EntityFrameworkCore; -using TodoTask = TodoList.Core.Entities.Task; +using TodoList.Core.Entities; -namespace TodoList.Api.Data; +namespace TodoList.Application.Data; -/// -/// 待办事项数据库上下文类,用于 Entity Framework Core 数据访问 -/// public class TodoDbContext : DbContext { - /// - /// 构造函数,初始化数据库上下文 - /// - /// 数据库上下文选项 public TodoDbContext(DbContextOptions options) : base(options) { } - /// - /// 任务数据集 - /// - public DbSet Tasks { get; set; } + public DbSet Tasks { get; set; } - /// - /// 配置模型关系和约束 - /// - /// 模型构建器 protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - modelBuilder.Entity(entity => + modelBuilder.Entity(entity => { + entity.ToTable("Tasks"); entity.HasKey(e => e.Id); entity.Property(e => e.Title).IsRequired().HasMaxLength(200); - entity.Property(e => e.Priority).HasDefaultValue(TodoList.Core.Entities.TaskPriority.Medium); + entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium); entity.Property(e => e.IsCompleted).HasDefaultValue(false); entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')"); entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')"); diff --git a/src/TodoList.Application/DynamicApi/DynamicApiExtensions.cs b/src/TodoList.Application/DynamicApi/DynamicApiExtensions.cs new file mode 100644 index 0000000..a116780 --- /dev/null +++ b/src/TodoList.Application/DynamicApi/DynamicApiExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Builder; + +namespace TodoList.Application.DynamicApi; + +public static class DynamicApiExtensions +{ + public static IApplicationBuilder UseDynamicApi(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } +} diff --git a/src/TodoList.Application/DynamicApi/DynamicApiMiddleware.cs b/src/TodoList.Application/DynamicApi/DynamicApiMiddleware.cs new file mode 100644 index 0000000..fdff662 --- /dev/null +++ b/src/TodoList.Application/DynamicApi/DynamicApiMiddleware.cs @@ -0,0 +1,637 @@ +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using TodoList.Application.Interfaces; + +namespace TodoList.Application.DynamicApi; + +public class DynamicApiMiddleware +{ + private readonly RequestDelegate _next; + private readonly IServiceProvider _serviceProvider; + private static readonly Dictionary _serviceTypeCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + static DynamicApiMiddleware() + { + InitializeServiceTypeCache(); + } + + private static void InitializeServiceTypeCache() + { + var assembly = typeof(IDynamicApiService).Assembly; + var serviceTypes = assembly.GetTypes() + .Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t)) + .ToList(); + + foreach (var type in serviceTypes) + { + var cleanName = type.Name.EndsWith("Service") + ? type.Name.Substring(0, type.Name.Length - "Service".Length) + : type.Name; + + if (cleanName.StartsWith("I") && cleanName.Length > 1 && char.IsUpper(cleanName[1])) + { + cleanName = cleanName.Substring(1); + } + + if (cleanName.EndsWith("App")) + { + cleanName = cleanName.Substring(0, cleanName.Length - "App".Length); + } + + _serviceTypeCache[cleanName] = type; + } + } + + public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider) + { + _next = next; + _serviceProvider = serviceProvider; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? string.Empty; + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length < 2 || segments[0] != "api") + { + await _next(context); + return; + } + + var serviceName = segments[1]; + var serviceType = FindDynamicApiService(serviceName); + + if (serviceType == null) + { + await _next(context); + return; + } + + if (!IsRemoteServiceEnabled(serviceType)) + { + await _next(context); + return; + } + + using var scope = _serviceProvider.CreateScope(); + var scopedServiceProvider = scope.ServiceProvider; + var service = scopedServiceProvider.GetService(serviceType); + if (service == null) + { + await _next(context); + return; + } + + var method = FindMethod(serviceType, context.Request.Method, segments.Skip(2).ToArray(), context); + if (method == null) + { + await _next(context); + return; + } + + if (!IsRemoteServiceEnabled(method)) + { + await _next(context); + return; + } + + try + { + var result = await InvokeMethod(service, method, context); + await WriteResponse(context, result, null, method.Name); + } + catch (Exception ex) + { + await WriteResponse(context, null, ex, method.Name); + } + } + + private Type? FindDynamicApiService(string serviceName) + { + _serviceTypeCache.TryGetValue(serviceName, out var serviceType); + return serviceType; + } + + private bool IsRemoteServiceEnabled(Type type) + { + var attribute = type.GetCustomAttribute(); + return attribute == null || attribute.IsEnabled; + } + + private bool IsRemoteServiceEnabled(MethodInfo method) + { + var attribute = method.GetCustomAttribute(); + return attribute == null || attribute.IsEnabled; + } + + private MethodInfo? FindMethod(Type serviceType, string httpMethod, string[] pathSegments, HttpContext context) + { + var methods = serviceType.GetMethods(BindingFlags.Public | BindingFlags.Instance); + + var matchedMethods = methods.Where(m => IsRemoteServiceEnabled(m)).ToList(); + + // First, try to handle special case: /api/task/13/toggle + if (pathSegments.Length >= 2 && httpMethod == "PATCH") + { + var potentialMethodName = pathSegments[1]; + var potentialParamValue = pathSegments[0]; + + // Try to find method with matching name and single parameter + foreach (var method in matchedMethods) + { + if (MatchesHttpMethod(method, httpMethod)) + { + var parameters = method.GetParameters(); + if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType)) + { + // Try to match with normalized method name + var normalizedMethodName = method.Name; + if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5); + } + + if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase)) + { + return method; + } + + // For ToggleCompleteAsync, try to match with "toggle" + if (normalizedMethodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) && + potentialMethodName.Equals("toggle", StringComparison.OrdinalIgnoreCase)) + { + return method; + } + } + } + } + } + + // Try to match with method name from path segments + var methodName = pathSegments.Length > 0 ? pathSegments[0] : null; + if (!string.IsNullOrEmpty(methodName)) + { + var exactMatch = matchedMethods.FirstOrDefault(m => + m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase) && + MatchesHttpMethod(m, httpMethod)); + + if (exactMatch != null) + return exactMatch; + + // Try to match method names with different naming conventions + foreach (var method in matchedMethods) + { + if (MatchesHttpMethod(method, httpMethod)) + { + // For GetSubTasksAsync, try to match with "subtasks" + var normalizedMethodName = method.Name; + if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(3); + } + if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5); + } + + if (normalizedMethodName.Equals(methodName, StringComparison.OrdinalIgnoreCase)) + { + return method; + } + + // For GetActiveTasksAsync, try to match with "active" + if (normalizedMethodName.Equals($"{methodName}Tasks", StringComparison.OrdinalIgnoreCase)) + { + return method; + } + } + } + } + + // First, try to handle special case: /api/task/13/subtasks + if (pathSegments.Length >= 2) + { + var potentialMethodName = pathSegments[1]; + var potentialParamValue = pathSegments[0]; + + // Try to find method with matching name and single parameter + foreach (var method in matchedMethods) + { + if (MatchesHttpMethod(method, httpMethod)) + { + var parameters = method.GetParameters(); + if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType)) + { + // Try to match with normalized method name + var normalizedMethodName = method.Name; + if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(3); + } + if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5); + } + + if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase)) + { + return method; + } + } + } + } + } + + foreach (var method in matchedMethods) + { + if (MatchesHttpMethod(method, httpMethod) && MatchesRoute(method, pathSegments)) + { + return method; + } + } + + // Try to match methods with parameters from path + foreach (var method in matchedMethods) + { + if (MatchesHttpMethod(method, httpMethod)) + { + var parameters = method.GetParameters(); + if (parameters.Length > 0 && pathSegments.Length > 0) + { + // Check if all path segments can be mapped to parameters + bool canMapParameters = true; + for (int i = 0; i < pathSegments.Length; i++) + { + if (i >= parameters.Length) + { + canMapParameters = false; + break; + } + if (!IsSimpleType(parameters[i].ParameterType)) + { + canMapParameters = false; + break; + } + } + if (canMapParameters) + { + // Try to convert the first path segment to the parameter type + // to avoid trying to convert non-numeric strings to numbers + try + { + ConvertValue(pathSegments[0], parameters[0].ParameterType); + return method; + } + catch + { + // If conversion fails, skip this method + continue; + } + } + } + } + } + + return null; + } + + private bool MatchesHttpMethod(MethodInfo method, string httpMethod) + { + if (method.GetCustomAttribute() != null) + return httpMethod == "GET"; + + if (method.GetCustomAttribute() != null) + return httpMethod == "POST"; + + if (method.GetCustomAttribute() != null) + return httpMethod == "PUT"; + + if (method.GetCustomAttribute() != null) + return httpMethod == "DELETE"; + + if (method.GetCustomAttribute() != null) + return httpMethod == "PATCH"; + + // For ToggleCompleteAsync, use PATCH method + if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase)) + { + return httpMethod == "PATCH"; + } + + return GetHttpVerbByConvention(method.Name) == httpMethod; + } + + private string GetHttpVerbByConvention(string methodName) + { + if (methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) + return "GET"; + + if (methodName.StartsWith("Put", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) + return "PUT"; + + if (methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Remove", StringComparison.OrdinalIgnoreCase)) + return "DELETE"; + + if (methodName.StartsWith("Post", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) || + methodName.StartsWith("Insert", StringComparison.OrdinalIgnoreCase)) + return "POST"; + + if (methodName.StartsWith("Patch", StringComparison.OrdinalIgnoreCase)) + return "PATCH"; + + return "POST"; + } + + private bool MatchesRoute(MethodInfo method, string[] pathSegments) + { + var httpGetAttr = method.GetCustomAttribute(); + var httpPostAttr = method.GetCustomAttribute(); + var httpPutAttr = method.GetCustomAttribute(); + var httpDeleteAttr = method.GetCustomAttribute(); + var httpPatchAttr = method.GetCustomAttribute(); + + var route = httpGetAttr?.Route ?? httpPostAttr?.Route ?? + httpPutAttr?.Route ?? httpDeleteAttr?.Route ?? httpPatchAttr?.Route; + + if (!string.IsNullOrEmpty(route)) + { + var routeSegments = route.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (routeSegments.Length > 0 && pathSegments.Length > 0) + { + return routeSegments[0].Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase); + } + return routeSegments.Length == 0 && pathSegments.Length == 0; + } + + if (pathSegments.Length == 0) + { + return true; + } + + // Try to match with full method name + if (method.Name.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + // Try to match with normalized method name (without Get/Async prefix/suffix) + var normalizedMethodName = method.Name; + if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(3); + } + if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase)) + { + normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5); + } + + return normalizedMethodName.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase); + } + + private async Task InvokeMethod(object service, MethodInfo method, HttpContext context) + { + var parameters = method.GetParameters(); + var args = new List(); + + foreach (var param in parameters) + { + var fromBodyAttr = param.GetCustomAttribute(); + var fromQueryAttr = param.GetCustomAttribute(); + + if (fromBodyAttr != null) + { + var dto = await ReadDtoFromBody(context, param.ParameterType); + args.Add(dto); + } + else if (fromQueryAttr != null || IsSimpleType(param.ParameterType)) + { + var value = BindParameterFromQueryOrPath(param, context); + args.Add(value); + } + else + { + var dto = await ReadDtoFromBody(context, param.ParameterType); + args.Add(dto); + } + } + + var result = method.Invoke(service, args.ToArray()); + + if (result is not Task task) return result; + await task; + return task.GetType().GetProperty("Result")?.GetValue(task); + + } + + private bool IsSimpleType(Type type) + { + return type.IsPrimitive || + type == typeof(string) || + type == typeof(decimal) || + type == typeof(DateTime) || + type == typeof(Guid) || + Nullable.GetUnderlyingType(type) != null; + } + + private object? BindParameterFromQueryOrPath(ParameterInfo param, HttpContext context) + { + var paramName = param.Name ?? string.Empty; + + // Try to get value from path + var pathValue = GetPathValue(context, paramName); + if (!string.IsNullOrEmpty(pathValue)) + { + return ConvertValue(pathValue, param.ParameterType); + } + + // Try to get value from query string + var queryValue = context.Request.Query[paramName].FirstOrDefault(); + if (!string.IsNullOrEmpty(queryValue)) + { + return ConvertValue(queryValue, param.ParameterType); + } + + // For methods with single parameter, try to get value from path segments + var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments != null && segments.Length >= 3) + { + // For GET /api/task/13, segments = ["api", "task", "13"] + // For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"] + var methodName = segments.Length > 3 ? segments[3] : null; + var paramValue = segments[2]; + + // Check if this is a method with single parameter + var method = param.Member as MethodInfo; + if (method != null && method.GetParameters().Length == 1) + { + return ConvertValue(paramValue, param.ParameterType); + } + + // Special case for GetSubTasksAsync + if (methodName?.Equals("subtasks", StringComparison.OrdinalIgnoreCase) == true && + paramName.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase)) + { + return ConvertValue(paramValue, param.ParameterType); + } + } + + if (param.ParameterType.IsValueType && Nullable.GetUnderlyingType(param.ParameterType) == null) + { + throw new ArgumentException($"Parameter '{paramName}' is required"); + } + + return null; + } + + private object? ConvertValue(string value, Type targetType) + { + try + { + if (targetType == typeof(string)) + return value; + + if (targetType == typeof(int) || targetType == typeof(int?)) + return int.Parse(value); + + if (targetType == typeof(long) || targetType == typeof(long?)) + return long.Parse(value); + + if (targetType == typeof(bool) || targetType == typeof(bool?)) + return bool.Parse(value); + + if (targetType == typeof(decimal) || targetType == typeof(decimal?)) + return decimal.Parse(value); + + if (targetType == typeof(DateTime) || targetType == typeof(DateTime?)) + return DateTime.Parse(value); + + if (targetType == typeof(Guid) || targetType == typeof(Guid?)) + return Guid.Parse(value); + + return Convert.ChangeType(value, targetType); + } + catch + { + throw new ArgumentException($"Cannot convert '{value}' to {targetType.Name}"); + } + } + + private string? GetPathValue(HttpContext context, string? paramName) + { + var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments == null || segments.Length < 3) + return null; + + // Try to find parameter in path segments + // For GET /api/task/13, segments = ["api", "task", "13"] + // For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"] + if (segments.Length >= 3) + { + // First check if paramName is "id" (common case) + if (paramName?.Equals("id", StringComparison.OrdinalIgnoreCase) == true) + return segments[2]; + + // Then check if paramName is "parentTaskId" (for subtasks) + if (paramName?.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase) == true && segments.Length >= 3) + return segments[2]; + } + + return null; + } + + private async Task ReadDtoFromBody(HttpContext context, Type dtoType) + { + var body = await new StreamReader(context.Request.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) + return null; + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { + new System.Text.Json.Serialization.JsonStringEnumConverter() + } + }; + + return JsonSerializer.Deserialize(body, dtoType, options); + } + + private async Task WriteResponse(HttpContext context, object? result, Exception? exception, string methodName) + { + context.Response.ContentType = "application/json"; + + var message = methodName switch + { + _ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功", + _ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功", + _ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功", + _ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功", + _ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功", + _ => "操作成功" + }; + + var errorMessage = methodName switch + { + _ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败", + _ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败", + _ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败", + _ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败", + _ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败", + _ => "操作失败" + }; + + // Get friendly error message + var errors = new List(); + if (exception != null) + { + if (exception is Microsoft.EntityFrameworkCore.DbUpdateException dbEx) + { + var innerMessage = dbEx.InnerException?.Message ?? dbEx.Message; + if (innerMessage.Contains("no such table", StringComparison.OrdinalIgnoreCase)) + { + errors.Add("数据库表不存在,请尝试重启应用或重新初始化数据库。"); + } + else if (innerMessage.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase)) + { + errors.Add("该项已存在,请检查是否重复。"); + } + else + { + errors.Add($"数据库操作失败: {innerMessage}"); + } + } + else + { + errors.Add(exception.Message); + } + } + + var response = new + { + Success = exception == null, + Data = result, + Message = exception == null ? message : errorMessage, + Errors = errors.Count > 0 ? errors : null + }; + + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + context.Response.StatusCode = exception switch + { + KeyNotFoundException => 404, + ArgumentException => 400, + _ => 200 + }; + + await context.Response.WriteAsync(json); + } +} diff --git a/src/TodoList.Application/DynamicApi/HttpAttributes.cs b/src/TodoList.Application/DynamicApi/HttpAttributes.cs new file mode 100644 index 0000000..efd445b --- /dev/null +++ b/src/TodoList.Application/DynamicApi/HttpAttributes.cs @@ -0,0 +1,76 @@ +namespace TodoList.Application.DynamicApi; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HttpGetAttribute : Attribute +{ + public string? Route { get; set; } + + public HttpGetAttribute() + { + } + + public HttpGetAttribute(string route) + { + Route = route; + } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HttpPostAttribute : Attribute +{ + public string? Route { get; set; } + + public HttpPostAttribute() + { + } + + public HttpPostAttribute(string route) + { + Route = route; + } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HttpPutAttribute : Attribute +{ + public string? Route { get; set; } + + public HttpPutAttribute() + { + } + + public HttpPutAttribute(string route) + { + Route = route; + } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HttpDeleteAttribute : Attribute +{ + public string? Route { get; set; } + + public HttpDeleteAttribute() + { + } + + public HttpDeleteAttribute(string route) + { + Route = route; + } +} + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HttpPatchAttribute : Attribute +{ + public string? Route { get; set; } + + public HttpPatchAttribute() + { + } + + public HttpPatchAttribute(string route) + { + Route = route; + } +} diff --git a/src/TodoList.Application/DynamicApi/ParameterBindingAttributes.cs b/src/TodoList.Application/DynamicApi/ParameterBindingAttributes.cs new file mode 100644 index 0000000..7530fa2 --- /dev/null +++ b/src/TodoList.Application/DynamicApi/ParameterBindingAttributes.cs @@ -0,0 +1,11 @@ +namespace TodoList.Application.DynamicApi; + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] +public class FromQueryAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] +public class FromBodyAttribute : Attribute +{ +} diff --git a/src/TodoList.Application/DynamicApi/RemoteServiceAttribute.cs b/src/TodoList.Application/DynamicApi/RemoteServiceAttribute.cs new file mode 100644 index 0000000..d758d3a --- /dev/null +++ b/src/TodoList.Application/DynamicApi/RemoteServiceAttribute.cs @@ -0,0 +1,8 @@ +namespace TodoList.Application.DynamicApi; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)] +public class RemoteServiceAttribute : Attribute +{ + public bool IsEnabled { get; set; } = true; + public bool IsMetadataEnabled { get; set; } = true; +} diff --git a/src/TodoList.Application/Interfaces/IDynamicApiService.cs b/src/TodoList.Application/Interfaces/IDynamicApiService.cs new file mode 100644 index 0000000..c765b67 --- /dev/null +++ b/src/TodoList.Application/Interfaces/IDynamicApiService.cs @@ -0,0 +1,5 @@ +namespace TodoList.Application.Interfaces; + +public interface IDynamicApiService +{ +} diff --git a/src/TodoList.Application/Interfaces/ITaskService.cs b/src/TodoList.Application/Interfaces/ITaskService.cs new file mode 100644 index 0000000..4dd5e07 --- /dev/null +++ b/src/TodoList.Application/Interfaces/ITaskService.cs @@ -0,0 +1,17 @@ +using TodoList.Application.DynamicApi; +using TodoList.Application.Models; + +namespace TodoList.Application.Interfaces; + +public interface ITaskService : IDynamicApiService +{ + Task> GetAllTasksAsync(); + Task GetTaskByIdAsync(int id); + Task> GetActiveTasksAsync(); + Task> GetCompletedTasksAsync(); + Task CreateTaskAsync(CreateTaskDto dto); + Task UpdateTaskAsync(UpdateTaskDto dto); + Task ToggleCompleteAsync(int id); + Task DeleteTaskAsync(int id); + Task> GetSubTasksAsync(int parentTaskId); +} diff --git a/src/TodoList.Api/Migrations/20260313044926_InitialCreate.Designer.cs b/src/TodoList.Application/Migrations/20260313044926_InitialCreate.Designer.cs similarity index 91% rename from src/TodoList.Api/Migrations/20260313044926_InitialCreate.Designer.cs rename to src/TodoList.Application/Migrations/20260313044926_InitialCreate.Designer.cs index f6aacd2..931e04a 100644 --- a/src/TodoList.Api/Migrations/20260313044926_InitialCreate.Designer.cs +++ b/src/TodoList.Application/Migrations/20260313044926_InitialCreate.Designer.cs @@ -1,14 +1,14 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using TodoList.Api.Data; +using TodoList.Application.Data; #nullable disable -namespace TodoList.Api.Migrations +namespace TodoList.Application.Migrations { [DbContext(typeof(TodoDbContext))] [Migration("20260313044926_InitialCreate")] @@ -20,7 +20,7 @@ namespace TodoList.Api.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() diff --git a/src/TodoList.Api/Migrations/20260313044926_InitialCreate.cs b/src/TodoList.Application/Migrations/20260313044926_InitialCreate.cs similarity index 96% rename from src/TodoList.Api/Migrations/20260313044926_InitialCreate.cs rename to src/TodoList.Application/Migrations/20260313044926_InitialCreate.cs index 91dc37d..71551ef 100644 --- a/src/TodoList.Api/Migrations/20260313044926_InitialCreate.cs +++ b/src/TodoList.Application/Migrations/20260313044926_InitialCreate.cs @@ -1,9 +1,9 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace TodoList.Api.Migrations +namespace TodoList.Application.Migrations { /// public partial class InitialCreate : Migration diff --git a/src/TodoList.Api/Migrations/20260313092658_AddParentTaskId.Designer.cs b/src/TodoList.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs similarity index 85% rename from src/TodoList.Api/Migrations/20260313092658_AddParentTaskId.Designer.cs rename to src/TodoList.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs index db46dde..deb519c 100644 --- a/src/TodoList.Api/Migrations/20260313092658_AddParentTaskId.Designer.cs +++ b/src/TodoList.Application/Migrations/20260313092658_AddParentTaskId.Designer.cs @@ -1,14 +1,14 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using TodoList.Api.Data; +using TodoList.Application.Data; #nullable disable -namespace TodoList.Api.Migrations +namespace TodoList.Application.Migrations { [DbContext(typeof(TodoDbContext))] [Migration("20260313092658_AddParentTaskId")] @@ -20,7 +20,7 @@ namespace TodoList.Api.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -61,9 +61,9 @@ namespace TodoList.Api.Migrations b.ToTable("Tasks"); }); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { - b.HasOne("TodoList.Core.Entities.Task", "ParentTask") + b.HasOne("TodoList.Core.Entities.TaskEntity", "ParentTask") .WithMany("SubTasks") .HasForeignKey("ParentTaskId") .OnDelete(DeleteBehavior.Restrict); @@ -71,7 +71,7 @@ namespace TodoList.Api.Migrations b.Navigation("ParentTask"); }); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { b.Navigation("SubTasks"); }); diff --git a/src/TodoList.Api/Migrations/20260313092658_AddParentTaskId.cs b/src/TodoList.Application/Migrations/20260313092658_AddParentTaskId.cs similarity index 93% rename from src/TodoList.Api/Migrations/20260313092658_AddParentTaskId.cs rename to src/TodoList.Application/Migrations/20260313092658_AddParentTaskId.cs index 436b364..a3b931c 100644 --- a/src/TodoList.Api/Migrations/20260313092658_AddParentTaskId.cs +++ b/src/TodoList.Application/Migrations/20260313092658_AddParentTaskId.cs @@ -1,8 +1,8 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace TodoList.Api.Migrations +namespace TodoList.Application.Migrations { /// public partial class AddParentTaskId : Migration diff --git a/src/TodoList.Api/Migrations/TodoDbContextModelSnapshot.cs b/src/TodoList.Application/Migrations/TodoDbContextModelSnapshot.cs similarity index 85% rename from src/TodoList.Api/Migrations/TodoDbContextModelSnapshot.cs rename to src/TodoList.Application/Migrations/TodoDbContextModelSnapshot.cs index 7974f66..18464f8 100644 --- a/src/TodoList.Api/Migrations/TodoDbContextModelSnapshot.cs +++ b/src/TodoList.Application/Migrations/TodoDbContextModelSnapshot.cs @@ -1,13 +1,13 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using TodoList.Api.Data; +using TodoList.Application.Data; #nullable disable -namespace TodoList.Api.Migrations +namespace TodoList.Application.Migrations { [DbContext(typeof(TodoDbContext))] partial class TodoDbContextModelSnapshot : ModelSnapshot @@ -17,7 +17,7 @@ namespace TodoList.Api.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -58,9 +58,9 @@ namespace TodoList.Api.Migrations b.ToTable("Tasks"); }); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { - b.HasOne("TodoList.Core.Entities.Task", "ParentTask") + b.HasOne("TodoList.Core.Entities.TaskEntity", "ParentTask") .WithMany("SubTasks") .HasForeignKey("ParentTaskId") .OnDelete(DeleteBehavior.Restrict); @@ -68,7 +68,7 @@ namespace TodoList.Api.Migrations b.Navigation("ParentTask"); }); - modelBuilder.Entity("TodoList.Core.Entities.Task", b => + modelBuilder.Entity("TodoList.Core.Entities.TaskEntity", b => { b.Navigation("SubTasks"); }); diff --git a/src/TodoList.Application/Models/TaskModels.cs b/src/TodoList.Application/Models/TaskModels.cs new file mode 100644 index 0000000..9f083a3 --- /dev/null +++ b/src/TodoList.Application/Models/TaskModels.cs @@ -0,0 +1,47 @@ +using TodoList.Core.Entities; +using System.Text.Json.Serialization; + +namespace TodoList.Application.Models; + +public class CreateTaskDto +{ + public string Title { get; set; } = string.Empty; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public TaskPriority Priority { get; set; } = TaskPriority.Medium; + + public int? ParentTaskId { get; set; } +} + +public class UpdateTaskDto +{ + public int Id { get; set; } + + public string? Title { get; set; } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public TaskPriority? Priority { get; set; } +} + +public class TaskDto +{ + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public TaskPriority Priority { get; set; } + + public bool IsCompleted { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public int? ParentTaskId { get; set; } + public List SubTasks { get; set; } = new(); +} + +public class ApiResponse +{ + public bool Success { get; set; } + public T? Data { get; set; } + public string Message { get; set; } = string.Empty; + public List Errors { get; set; } = new(); +} diff --git a/src/TodoList.Application/Properties/launchSettings.json b/src/TodoList.Application/Properties/launchSettings.json new file mode 100644 index 0000000..fcfe098 --- /dev/null +++ b/src/TodoList.Application/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "TodoList.Application": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53852;http://localhost:53853" + } + } +} \ No newline at end of file diff --git a/src/TodoList.Application/Repositories/TaskRepository.cs b/src/TodoList.Application/Repositories/TaskRepository.cs new file mode 100644 index 0000000..c89cefe --- /dev/null +++ b/src/TodoList.Application/Repositories/TaskRepository.cs @@ -0,0 +1,79 @@ +using Microsoft.EntityFrameworkCore; +using TodoList.Application.Data; +using TodoList.Core.Entities; +using TodoList.Core.Interfaces; + +namespace TodoList.Application.Repositories; + +public class TaskRepository : ITaskRepository +{ + private readonly TodoDbContext _context; + + public TaskRepository(TodoDbContext context) + { + _context = context; + } + + public async Task> GetAllAsync() + { + return await _context.Tasks + .Include(t => t.SubTasks) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.Tasks + .Include(t => t.SubTasks) + .FirstOrDefaultAsync(t => t.Id == id); + } + + public async Task> GetActiveTasksAsync() + { + return await _context.Tasks + .Where(t => !t.IsCompleted) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(); + } + + public async Task> GetCompletedTasksAsync() + { + return await _context.Tasks + .Where(t => t.IsCompleted) + .OrderByDescending(t => t.UpdatedAt) + .ToListAsync(); + } + + public async Task AddAsync(TaskEntity taskEntity) + { + _context.Tasks.Add(taskEntity); + await _context.SaveChangesAsync(); + return taskEntity; + } + + public async Task UpdateAsync(TaskEntity taskEntity) + { + taskEntity.UpdatedAt = DateTime.UtcNow; + _context.Tasks.Update(taskEntity); + await _context.SaveChangesAsync(); + return taskEntity; + } + + public async Task DeleteAsync(int id) + { + var task = await _context.Tasks.FindAsync(id); + if (task != null) + { + _context.Tasks.Remove(task); + await _context.SaveChangesAsync(); + } + } + + public async Task> GetSubTasksAsync(int parentTaskId) + { + return await _context.Tasks + .Where(t => t.ParentTaskId == parentTaskId) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(); + } +} diff --git a/src/TodoList.Application/ServiceCollectionExtensions.cs b/src/TodoList.Application/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..0bceef9 --- /dev/null +++ b/src/TodoList.Application/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TodoList.Application.Data; +using TodoList.Application.Interfaces; +using TodoList.Application.Repositories; +using TodoList.Application.Services; +using TodoList.Core.Interfaces; +using ITaskService = TodoList.Application.Interfaces.ITaskService; + +namespace TodoList.Application; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services, string connectionString) + { + services.AddDbContext(options => + options.UseSqlite(connectionString, b => b.MigrationsAssembly("TodoList.Application"))); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/TodoList.Application/Services/TaskService.cs b/src/TodoList.Application/Services/TaskService.cs new file mode 100644 index 0000000..8ca6fb6 --- /dev/null +++ b/src/TodoList.Application/Services/TaskService.cs @@ -0,0 +1,127 @@ +using TodoList.Application.Interfaces; +using TodoList.Application.Models; +using TodoList.Core.Entities; +using TodoList.Core.Interfaces; + +namespace TodoList.Application.Services; + +public class TaskService : ITaskService +{ + private readonly ITaskRepository _taskRepository; + + public TaskService(ITaskRepository taskRepository) + { + _taskRepository = taskRepository; + } + + public async Task> GetAllTasksAsync() + { + var tasks = await _taskRepository.GetAllAsync(); + return tasks.Select(MapToDto).ToList(); + } + + public async Task GetTaskByIdAsync(int id) + { + var task = await _taskRepository.GetByIdAsync(id); + return task != null ? MapToDto(task) : null; + } + + public async Task> GetActiveTasksAsync() + { + var allTasks = await _taskRepository.GetAllAsync(); + return allTasks.Where(t => !t.IsCompleted).Select(MapToDto).ToList(); + } + + public async Task> GetCompletedTasksAsync() + { + var allTasks = await _taskRepository.GetAllAsync(); + return allTasks.Where(t => t.IsCompleted).Select(MapToDto).ToList(); + } + + public async Task CreateTaskAsync(CreateTaskDto dto) + { + var task = new TaskEntity + { + Title = dto.Title, + Priority = dto.Priority, + IsCompleted = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ParentTaskId = dto.ParentTaskId + }; + + var createdTask = await _taskRepository.AddAsync(task); + return MapToDto(createdTask); + } + + public async Task UpdateTaskAsync(UpdateTaskDto dto) + { + var task = await _taskRepository.GetByIdAsync(dto.Id); + if (task == null) + { + throw new KeyNotFoundException($"Task with ID {dto.Id} not found"); + } + + if (!string.IsNullOrEmpty(dto.Title)) + { + task.Title = dto.Title; + } + + if (dto.Priority.HasValue) + { + task.Priority = dto.Priority.Value; + } + + task.UpdatedAt = DateTime.UtcNow; + + var updatedTask = await _taskRepository.UpdateAsync(task); + return MapToDto(updatedTask); + } + + public async Task ToggleCompleteAsync(int id) + { + var task = await _taskRepository.GetByIdAsync(id); + if (task == null) + { + throw new KeyNotFoundException($"Task with ID {id} not found"); + } + + task.IsCompleted = !task.IsCompleted; + task.UpdatedAt = DateTime.UtcNow; + + var updatedTask = await _taskRepository.UpdateAsync(task); + return MapToDto(updatedTask); + } + + public async Task DeleteTaskAsync(int id) + { + var task = await _taskRepository.GetByIdAsync(id); + if (task == null) + { + throw new KeyNotFoundException($"Task with ID {id} not found"); + } + + await _taskRepository.DeleteAsync(id); + } + + public async Task> GetSubTasksAsync(int parentTaskId) + { + var allTasks = await _taskRepository.GetAllAsync(); + return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList(); + } + + private TaskDto MapToDto(TaskEntity task) + { + return new TaskDto + { + Id = task.Id, + Title = task.Title, + Priority = task.Priority, + IsCompleted = task.IsCompleted, + CreatedAt = task.CreatedAt, + UpdatedAt = task.UpdatedAt, + ParentTaskId = task.ParentTaskId, + SubTasks = task.SubTasks.Select(MapToDto).ToList() + }; + } +} diff --git a/src/TodoList.Application/TodoList.Application.csproj b/src/TodoList.Application/TodoList.Application.csproj new file mode 100644 index 0000000..e5087bf --- /dev/null +++ b/src/TodoList.Application/TodoList.Application.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + Library + + + + + + + + + + + + + + + diff --git a/src/TodoList.Core/Class1.cs b/src/TodoList.Core/Class1.cs deleted file mode 100644 index 518b2f5..0000000 --- a/src/TodoList.Core/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace TodoList.Core; - -public class Class1 -{ - -} diff --git a/src/TodoList.Core/Entities/Task.cs b/src/TodoList.Core/Entities/TaskEntity.cs similarity index 89% rename from src/TodoList.Core/Entities/Task.cs rename to src/TodoList.Core/Entities/TaskEntity.cs index bd2279d..c355257 100644 --- a/src/TodoList.Core/Entities/Task.cs +++ b/src/TodoList.Core/Entities/TaskEntity.cs @@ -3,7 +3,7 @@ namespace TodoList.Core.Entities; /// /// 任务实体类,表示一个待办事项 /// -public class Task +public class TaskEntity { /// /// 任务唯一标识符 @@ -43,10 +43,10 @@ public class Task /// /// 父任务导航属性 /// - public Task? ParentTask { get; set; } + public TaskEntity? ParentTask { get; set; } /// /// 子任务集合 /// - public List SubTasks { get; set; } = new(); + public List SubTasks { get; set; } = new(); } diff --git a/src/TodoList.Core/Interfaces/ITaskRepository.cs b/src/TodoList.Core/Interfaces/ITaskRepository.cs index aa1ae3f..d9f9505 100644 --- a/src/TodoList.Core/Interfaces/ITaskRepository.cs +++ b/src/TodoList.Core/Interfaces/ITaskRepository.cs @@ -1,4 +1,4 @@ -using TodoTask = TodoList.Core.Entities.Task; +using TodoList.Core.Entities; namespace TodoList.Core.Interfaces; @@ -11,40 +11,40 @@ public interface ITaskRepository /// 获取所有任务 /// /// 任务列表 - System.Threading.Tasks.Task> GetAllAsync(); + System.Threading.Tasks.Task> GetAllAsync(); /// /// 根据ID获取指定任务 /// /// 任务ID /// 任务对象,如果不存在则返回null - System.Threading.Tasks.Task GetByIdAsync(int id); + System.Threading.Tasks.Task GetByIdAsync(int id); /// /// 获取所有未完成的任务 /// /// 未完成任务列表 - System.Threading.Tasks.Task> GetActiveTasksAsync(); + System.Threading.Tasks.Task> GetActiveTasksAsync(); /// /// 获取所有已完成的任务 /// /// 已完成任务列表 - System.Threading.Tasks.Task> GetCompletedTasksAsync(); + System.Threading.Tasks.Task> GetCompletedTasksAsync(); /// /// 添加新任务 /// - /// 要添加的任务对象 + /// 要添加的任务对象 /// 添加后的任务对象(包含生成的ID) - System.Threading.Tasks.Task AddAsync(TodoTask task); + System.Threading.Tasks.Task AddAsync(TaskEntity taskEntity); /// /// 更新任务 /// - /// 要更新的任务对象 + /// 要更新的任务对象 /// 更新后的任务对象 - System.Threading.Tasks.Task UpdateAsync(TodoTask task); + System.Threading.Tasks.Task UpdateAsync(TaskEntity taskEntity); /// /// 删除指定ID的任务 @@ -57,5 +57,5 @@ public interface ITaskRepository /// /// 父任务ID /// 子任务列表 - System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId); + System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId); } diff --git a/src/TodoList.Core/Interfaces/ITaskService.cs b/src/TodoList.Core/Interfaces/ITaskService.cs deleted file mode 100644 index 0fda3fc..0000000 --- a/src/TodoList.Core/Interfaces/ITaskService.cs +++ /dev/null @@ -1,73 +0,0 @@ -using TodoTask = TodoList.Core.Entities.Task; -using TodoList.Core.Entities; - -namespace TodoList.Core.Interfaces; - -/// -/// 任务服务接口,定义任务业务逻辑操作 -/// -public interface ITaskService -{ - /// - /// 获取所有任务 - /// - /// 任务列表 - System.Threading.Tasks.Task> GetAllTasksAsync(); - - /// - /// 根据ID获取指定任务 - /// - /// 任务ID - /// 任务对象,如果不存在则返回null - System.Threading.Tasks.Task GetTaskByIdAsync(int id); - - /// - /// 获取所有未完成的任务 - /// - /// 未完成任务列表 - System.Threading.Tasks.Task> GetActiveTasksAsync(); - - /// - /// 获取所有已完成的任务 - /// - /// 已完成任务列表 - System.Threading.Tasks.Task> GetCompletedTasksAsync(); - - /// - /// 创建新任务 - /// - /// 任务标题 - /// 任务优先级 - /// 父任务ID(可选) - /// 创建的任务对象 - System.Threading.Tasks.Task CreateTaskAsync(string title, TaskPriority priority, int? parentTaskId = null); - - /// - /// 更新任务 - /// - /// 任务ID - /// 新的任务标题(可选) - /// 新的任务优先级(可选) - /// 更新后的任务对象 - System.Threading.Tasks.Task UpdateTaskAsync(int id, string? title = null, TaskPriority? priority = null); - - /// - /// 切换任务的完成状态 - /// - /// 任务ID - /// 更新后的任务对象 - System.Threading.Tasks.Task ToggleCompleteAsync(int id); - - /// - /// 删除指定ID的任务 - /// - /// 任务ID - System.Threading.Tasks.Task DeleteTaskAsync(int id); - - /// - /// 获取指定父任务的所有子任务 - /// - /// 父任务ID - /// 子任务列表 - System.Threading.Tasks.Task> GetSubTasksAsync(int parentTaskId); -} diff --git a/src/TodoList.Core/TodoList.Core.csproj b/src/TodoList.Core/TodoList.Core.csproj index b760144..206c6e5 100644 --- a/src/TodoList.Core/TodoList.Core.csproj +++ b/src/TodoList.Core/TodoList.Core.csproj @@ -1,9 +1,11 @@ - + net10.0 enable enable + false + false diff --git a/src/TodoList.Api/Program.cs b/src/TodoList.Host/Program.cs similarity index 52% rename from src/TodoList.Api/Program.cs rename to src/TodoList.Host/Program.cs index f26197c..62948c7 100644 --- a/src/TodoList.Api/Program.cs +++ b/src/TodoList.Host/Program.cs @@ -1,26 +1,17 @@ using System.Text.Json; using Microsoft.EntityFrameworkCore; -using TodoList.Api.Data; -using TodoList.Api.Repositories; -using TodoList.Api.Services; -using TodoList.Core.Interfaces; +using TodoList.Application; +using TodoList.Application.DynamicApi; +using TodoList.Application.Interfaces; +using TodoList.Application.Models; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers() - .AddJsonOptions(options => - { - options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - }); - builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); +builder.Services.AddAuthorization(); -builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=todolist.db")); - -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddApplicationServices("Data Source=todolist.db"); builder.Services.AddCors(options => { @@ -34,6 +25,13 @@ builder.Services.AddCors(options => var app = builder.Build(); +// Apply database migrations +using (var scope = app.Services.CreateScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.Migrate(); +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); @@ -43,6 +41,6 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseCors("AllowAll"); app.UseAuthorization(); -app.MapControllers(); +app.UseDynamicApi(); app.Run(); diff --git a/src/TodoList.Api/Properties/launchSettings.json b/src/TodoList.Host/Properties/launchSettings.json similarity index 90% rename from src/TodoList.Api/Properties/launchSettings.json rename to src/TodoList.Host/Properties/launchSettings.json index 2edd019..86fb2be 100644 --- a/src/TodoList.Api/Properties/launchSettings.json +++ b/src/TodoList.Host/Properties/launchSettings.json @@ -1,11 +1,11 @@ -{ +{ "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { "http": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://localhost:5057", + "applicationUrl": "http://localhost:5173", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:7175;http://localhost:5057", + "applicationUrl": "https://localhost:7175;http://localhost:5173", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/TodoList.Api/TodoList.Api.csproj b/src/TodoList.Host/TodoList.Host.csproj similarity index 69% rename from src/TodoList.Api/TodoList.Api.csproj rename to src/TodoList.Host/TodoList.Host.csproj index ee793ea..2d2c2b7 100644 --- a/src/TodoList.Api/TodoList.Api.csproj +++ b/src/TodoList.Host/TodoList.Host.csproj @@ -7,17 +7,15 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive all - - + - + diff --git a/src/TodoList.Api/appsettings.Development.json b/src/TodoList.Host/appsettings.Development.json similarity index 100% rename from src/TodoList.Api/appsettings.Development.json rename to src/TodoList.Host/appsettings.Development.json diff --git a/src/TodoList.Api/appsettings.json b/src/TodoList.Host/appsettings.json similarity index 100% rename from src/TodoList.Api/appsettings.json rename to src/TodoList.Host/appsettings.json diff --git a/src/TodoList.Api/todolist.db b/src/TodoList.Host/todolist.db similarity index 97% rename from src/TodoList.Api/todolist.db rename to src/TodoList.Host/todolist.db index a42517e4cbefe26d5b2f1b3e6ef20d57e9e64fed..7ae11450e32c834f1e29595660e1fee7a25fa455 100644 GIT binary patch delta 363 zcmZo@U}|V!njp>CI#I@%v2|m@N`A&$n*{?d@GGb=GBfxa85o)A8khi)f}x?6v4xef zfu4bpp{aqT<>p`VTnZ8_{I3}KfAfFif5rbE2w%bAWj9H4hCmV21Z+F zxC;yw3{9XLX)|EH1)8<2e{OB~gqwvN8tRV4-JfWMpY#W(hG7*(`(tV*>*t iOD92)DU6(=j*}be<)sZVm73}q8e3SJnN8kR?*;(E1!i~v delta 336 zcmZo@U}|V!njp<+I#I@%(R5?NN`A)Mn*{?d^2;kTGBfxZ85o)A8W`&u8Y>uBS{a#J z85!%D8<-oJ7;gS0&!r&2%>SN||2O|P{`dT^kl|)QferkV&-lx-v9mHWGV*SI?=K+0 zD7YzsML>d??->LCF1}~{Wqi~5Lim_@*YO4bAvaLZd!EhQwM~qZ?d!Pwefc>U961;{ zwVh#(Ffvy#G_f);u`)H(votX`wlL;>wzd1k`l(OXPI$6=_tUP0I8>S#8k$UgR_7V$ z<;=mrDUUGFRKd{9%D~Xd#6-{1+|tOx+z8VsbTzI598Q8949-BO2|G`2sFw#~31(vx JqshDK-2j69Uyc9( diff --git a/src/TodoList.Maui/App.xaml.cs b/src/TodoList.Maui/App.xaml.cs index 14864dd..3c57495 100644 --- a/src/TodoList.Maui/App.xaml.cs +++ b/src/TodoList.Maui/App.xaml.cs @@ -1,18 +1,26 @@ using Microsoft.Extensions.DependencyInjection; +using System; +using System.IO; using TodoList.Maui.Services; using TodoList.Maui.Views; using TodoList.Maui.Models; +#if WINDOWS +using System.Runtime.InteropServices; +using Windowing = Microsoft.UI.Windowing; +using WinUiWindow = Microsoft.UI.Xaml.Window; +using WinRT.Interop; +#endif namespace TodoList.Maui; -public partial class App : Application +public partial class App : global::Microsoft.Maui.Controls.Application { private readonly IHotKeySettingsService _settingsService; private readonly IGlobalHotKeyService _hotKeyService; private readonly ISystemTrayService _trayService; private Window? _mainWindow; - private Window? _quickEntryWindow; private bool _isHotkeyRegistered; + private bool _isWindowCentered; public App(IServiceProvider serviceProvider) { @@ -23,30 +31,79 @@ public partial class App : Application _trayService = serviceProvider.GetRequiredService(); } - protected override Window CreateWindow(IActivationState? activationState) +protected override Microsoft.Maui.Controls.Window CreateWindow(IActivationState? activationState) { - _mainWindow = new Window(new MainPage()) + _mainWindow = new Microsoft.Maui.Controls.Window(new MainPage()) { Width = 450, - Height = 640 + Height = 640, + Title = AppMetadata.GetWindowTitle() }; +#if WINDOWS + _mainWindow.TitleBar = CreateWindowTitleBar(); +#endif + _mainWindow.Destroying += (s, e) => { + if (_isHotkeyRegistered) + { + _hotKeyService.UnregisterHotKey(); + _isHotkeyRegistered = false; + } + _trayService.Dispose(); }; _mainWindow.Created += (s, e) => { + _trayService.Initialize(_mainWindow, ShowMainWindow, ExitApplication); + RegisterHotkeyWhenReady(); + +#if WINDOWS + MainThread.BeginInvokeOnMainThread(() => + { + if (_mainWindow.Handler?.PlatformView is WinUiWindow platformWindow) + { + // Ensure app doesn't shutdown when main window closes (we hide it) + platformWindow.AppWindow.Closing += (sender, args) => + { + args.Cancel = true; + new TodoList.Maui.Platforms.Windows.WindowsWindowService().HideWindow(_mainWindow); + }; + + CenterMainWindow(platformWindow); + ConfigureWindowsTitleBar(platformWindow); + } + }); +#endif }; return _mainWindow; } + private void RegisterHotkeyWhenReady(int attempt = 0) + { + if (_mainWindow == null) return; + +#if WINDOWS + if (_mainWindow.Handler?.PlatformView is not Window) + { + if (attempt < 30) + { + _mainWindow.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(100), () => RegisterHotkeyWhenReady(attempt + 1)); + } + return; + } +#endif + + RegisterHotkey(); + } + private void OnHotKeyPressed() { MainThread.BeginInvokeOnMainThread(() => { - ShowQuickEntryWindow(); + ShowMainWindow(); }); } @@ -55,24 +112,29 @@ public partial class App : Application if (_mainWindow != null) { _mainWindow.Dispatcher.Dispatch(() => - { + { #if WINDOWS - if (_mainWindow.Handler != null) - { - var platformWindow = _mainWindow.Handler.PlatformView as Microsoft.UI.Xaml.Window; - platformWindow?.Activate(); - } + if (_mainWindow.Handler != null) + { + new TodoList.Maui.Platforms.Windows.WindowsWindowService().RestoreWindow(_mainWindow); + var platformWindow = _mainWindow.Handler.PlatformView as WinUiWindow; + platformWindow?.Activate(); + } #else - _mainWindow.Focus(); + if (global::Application.Current != null && + !global::Application.Current.Windows.Contains(_mainWindow)) + { + global::Application.Current.OpenWindow(_mainWindow); + } #endif - }); + }); } } private void ExitApplication() { _trayService?.Dispose(); - Application.Current?.Quit(); + global::Microsoft.Maui.Controls.Application.Current?.Quit(); } private void RegisterHotkey() @@ -105,33 +167,125 @@ public partial class App : Application } } - private void ShowQuickEntryWindow() +#if WINDOWS + private void ConfigureWindowsTitleBar(WinUiWindow platformWindow) { - if (_quickEntryWindow == null) + var title = AppMetadata.GetWindowTitle(); + + platformWindow.Title = title; + if (_mainWindow != null) { - _quickEntryWindow = new Window(new QuickEntryPage(() => - { - if (_quickEntryWindow != null) - { - Application.Current?.CloseWindow(_quickEntryWindow); - _quickEntryWindow = null; - } - })) - { - Width = 400, - Height = 300 - }; + _mainWindow.Title = title; } - Application.Current?.OpenWindow(_quickEntryWindow); -#if WINDOWS - if (_quickEntryWindow.Handler != null) + var appWindow = platformWindow.AppWindow; + if (appWindow != null) { - var platformWindow = _quickEntryWindow.Handler.PlatformView as Microsoft.UI.Xaml.Window; - platformWindow?.Activate(); + appWindow.Title = title; + + var hWnd = WindowNative.GetWindowHandle(platformWindow); + if (hWnd != IntPtr.Zero) + { + SetWindowText(hWnd, title); + } + + var iconPath = Path.Combine(AppContext.BaseDirectory, "icon.ico"); + if (File.Exists(iconPath)) + { + appWindow.SetIcon(iconPath); + } + + var titleBar = appWindow.TitleBar; + titleBar.IconShowOptions = Windowing.IconShowOptions.ShowIconAndSystemMenu; + + titleBar.BackgroundColor = Microsoft.UI.Colors.Transparent; + titleBar.InactiveBackgroundColor = Microsoft.UI.Colors.Transparent; + titleBar.ButtonBackgroundColor = Microsoft.UI.Colors.Transparent; + titleBar.ButtonInactiveBackgroundColor = Microsoft.UI.Colors.Transparent; + titleBar.ButtonHoverBackgroundColor = Microsoft.UI.Colors.Transparent; + titleBar.ButtonPressedBackgroundColor = Microsoft.UI.Colors.Transparent; + titleBar.ButtonForegroundColor = Microsoft.UI.Colors.Black; + titleBar.ButtonInactiveForegroundColor = Microsoft.UI.Colors.Black; + titleBar.ButtonHoverForegroundColor = Microsoft.UI.Colors.Black; + titleBar.ButtonPressedForegroundColor = Microsoft.UI.Colors.Black; } -#else - _quickEntryWindow.Focus(); -#endif } -} \ No newline at end of file + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "SetWindowTextW")] + private static extern bool SetWindowText(IntPtr hWnd, string lpString); + + private static TitleBar CreateWindowTitleBar() + { + return new TitleBar + { + BackgroundColor = Microsoft.Maui.Graphics.Color.FromArgb("#F5F5F5"), + Icon = string.Empty, + Title = string.Empty, + ForegroundColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"), + LeadingContent = new HorizontalStackLayout + { + Spacing = 4, + Padding = new Microsoft.Maui.Thickness(10, 0, 0, 0), + VerticalOptions = LayoutOptions.Center, + Children = + { + new Image + { + Source = "icon.jpg", + WidthRequest = 22, + HeightRequest = 22, + VerticalOptions = LayoutOptions.Center + }, + new Label + { + Text = AppMetadata.GetTitleBarVersionText(), + FontFamily = "Microsoft YaHei UI", + TextColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"), + VerticalTextAlignment = Microsoft.Maui.TextAlignment.Center, + VerticalOptions = LayoutOptions.Center, + FontSize = 14 + } + } + } + }; + } + + private void CenterMainWindow(WinUiWindow platformWindow) + { + if (_isWindowCentered) return; + + var appWindow = platformWindow.AppWindow; + if (appWindow == null) return; + + var displayArea = Windowing.DisplayArea.GetFromWindowId( + appWindow.Id, + Windowing.DisplayAreaFallback.Primary); + + var workArea = displayArea.WorkArea; + + var windowWidthPx = appWindow.Size.Width; + var windowHeightPx = appWindow.Size.Height; + + if (windowWidthPx <= 0 || windowHeightPx <= 0) + { + var scale = platformWindow.Content?.XamlRoot?.RasterizationScale ?? 1.0; + windowWidthPx = windowWidthPx <= 0 ? (int)Math.Round((_mainWindow?.Width ?? 450) * scale) : windowWidthPx; + windowHeightPx = windowHeightPx <= 0 ? (int)Math.Round((_mainWindow?.Height ?? 640) * scale) : windowHeightPx; + } + + if (windowWidthPx <= 0 || windowHeightPx <= 0) return; + + var x = workArea.X + (workArea.Width - windowWidthPx) / 2; + var y = workArea.Y + (workArea.Height - windowHeightPx) / 2; + + if (windowWidthPx >= workArea.Width) x = workArea.X; + if (windowHeightPx >= workArea.Height) y = workArea.Y; + + x = Math.Max(workArea.X, Math.Min(x, workArea.X + workArea.Width - windowWidthPx)); + y = Math.Max(workArea.Y, Math.Min(y, workArea.Y + workArea.Height - windowHeightPx)); + + appWindow.Move(new Windows.Graphics.PointInt32(x, y)); + _isWindowCentered = true; + } +#endif +} diff --git a/src/TodoList.Maui/AppShell.xaml b/src/TodoList.Maui/AppShell.xaml index f1ac9fa..28863e4 100644 --- a/src/TodoList.Maui/AppShell.xaml +++ b/src/TodoList.Maui/AppShell.xaml @@ -9,7 +9,5 @@ - diff --git a/src/TodoList.Maui/BuildSetup.ps1 b/src/TodoList.Maui/BuildSetup.ps1 index 5074f51..e5d366f 100644 --- a/src/TodoList.Maui/BuildSetup.ps1 +++ b/src/TodoList.Maui/BuildSetup.ps1 @@ -1,47 +1,35 @@ $ErrorActionPreference = "Stop" -# Basic configuration $ScriptPath = $PSScriptRoot $ProjectFile = (Get-ChildItem -Path $ScriptPath -Filter "*.csproj" -File)[0].FullName $SetupScript = Join-Path $ScriptPath "setup.iss" -# Read version from project file $currentVersion = "1.0.0" [xml]$csproj = Get-Content $ProjectFile -if ($csproj.Project.PropertyGroup.ApplicationDisplayVersion) { +if ($csproj.Project.PropertyGroup.Version) { + $currentVersion = $csproj.Project.PropertyGroup.Version +} elseif ($csproj.Project.PropertyGroup.ApplicationDisplayVersion) { $currentVersion = $csproj.Project.PropertyGroup.ApplicationDisplayVersion } -# Increment version -$versionParts = $currentVersion.Split(".") -$patch = [int]$versionParts[2] + 1 -$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch - -# Update project version -$content = Get-Content $ProjectFile -Raw -$content = $content -replace ".*", "$newVersion" -Set-Content $ProjectFile -Value $content - -# Update setup script version if (Test-Path $SetupScript) { $issContent = Get-Content $SetupScript for ($i = 0; $i -lt $issContent.Count; $i++) { if ($issContent[$i] -like '#define MyAppVersion *') { - $issContent[$i] = '#define MyAppVersion "' + $newVersion + '"' + $issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"' break } } Set-Content $SetupScript -Value $issContent } -# Build project (MAUI Windows) -dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release -r win10-x64 --self-contained false -p:PublishSingleFile=true +Write-Host "Building TodoList.Maui (Release)..." -ForegroundColor Cyan +dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false if ($LASTEXITCODE -ne 0) { - Write-Error "Build failed" + Write-Error "MAUI build failed" exit 1 } -# Package $ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" if (Test-Path $ISCC) { & $ISCC $SetupScript @@ -53,3 +41,11 @@ if (Test-Path $ISCC) { } else { Write-Error "Inno Setup compiler not found" } + +$versionParts = $currentVersion.Split(".") +$patch = [int]$versionParts[2] + 1 +$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch + +$content = Get-Content $ProjectFile -Raw +$content = $content -replace ".*", "$newVersion" +Set-Content $ProjectFile -Value $content diff --git a/src/TodoList.Maui/MauiProgram.cs b/src/TodoList.Maui/MauiProgram.cs index fbf7b31..c642c17 100644 --- a/src/TodoList.Maui/MauiProgram.cs +++ b/src/TodoList.Maui/MauiProgram.cs @@ -1,4 +1,10 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Microsoft.Data.Sqlite; +using TodoList.Application; +using TodoList.Application.Data; +using TodoList.Maui.Models; using TodoList.Maui.Services; using TodoList.Maui.Services.Platforms; @@ -17,21 +23,88 @@ public static class MauiProgram fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); }); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(sp => new NullGlobalHotKeyService()); + var appSettings = LoadAppSettings(); + + // Set default connection string if not provided + if (string.IsNullOrEmpty(appSettings.WebServer.ConnectionString)) + { + var dbPath = Path.Combine(FileSystem.AppDataDirectory, "todolist.db"); + appSettings.WebServer.ConnectionString = $"Data Source={dbPath}"; + } + + builder.Services.AddSingleton(appSettings); + var connectionString = appSettings.WebServer.ConnectionString; + + builder.Services.AddApplicationServices(connectionString); + + builder.Services.AddSingleton(sp => + new HotKeySettingsService(sp.GetRequiredService())); + builder.Services.AddSingleton(sp => GlobalHotKeyServiceFactory.Create()); builder.Services.AddSingleton(sp => { #if WINDOWS - return new NullSystemTrayService(); + return new WindowsSystemTrayService(); #else return new NullSystemTrayService(); #endif }); + builder.Services.AddSingleton(); #if DEBUG - builder.Logging.AddDebug(); + builder.Logging.AddDebug(); #endif - return builder.Build(); + var app = builder.Build(); + + // Ensure database directory exists and apply migrations + using (var scope = app.Services.CreateScope()) + { + try + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Ensure database directory exists for the actual connection string + var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString); + var actualDbPath = sqliteBuilder.DataSource; + if (!string.IsNullOrEmpty(actualDbPath)) + { + // If it's a relative path, we might need to resolve it, + // but for SQLite, it's usually better to have absolute paths. + // For MAUI, FileSystem.AppDataDirectory returns an absolute path. + var dbDir = Path.GetDirectoryName(actualDbPath); + if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir)) + { + Directory.CreateDirectory(dbDir); + } + } + + // Ensure database is up to date + dbContext.Database.Migrate(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Database initialization failed: {ex.Message}"); + // Fallback to EnsureCreated if Migrate fails (though Migrate is preferred) + using var context = scope.ServiceProvider.GetRequiredService(); + context.Database.EnsureCreated(); + } + } + + var webServer = app.Services.GetRequiredService(); + _ = webServer.StartAsync(); + + return app; + } + + private static AppSettings LoadAppSettings() + { + var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (!File.Exists(settingsPath)) + { + return new AppSettings(); + } + + var json = File.ReadAllText(settingsPath); + return JsonSerializer.Deserialize(json) ?? new AppSettings(); } } diff --git a/src/TodoList.Maui/Models/AppSettings.cs b/src/TodoList.Maui/Models/AppSettings.cs new file mode 100644 index 0000000..93bfd42 --- /dev/null +++ b/src/TodoList.Maui/Models/AppSettings.cs @@ -0,0 +1,44 @@ +using System.Text.Json.Serialization; + +namespace TodoList.Maui.Models; + +public class AppSettings +{ + [JsonPropertyName("WebServer")] + public WebServerSettings WebServer { get; set; } = new(); + + [JsonPropertyName("HotKey")] + public HotKeyDefaultSettings HotKey { get; set; } = new(); +} + +public class WebServerSettings +{ + [JsonPropertyName("Port")] + public int Port { get; set; } = 5057; + + [JsonPropertyName("IsUsingStatic")] + public bool IsUsingStatic { get; set; } = true; + + [JsonPropertyName("ConnectionString")] + public string ConnectionString { get; set; } = ""; + + + [JsonPropertyName("HostUrl")] + public string HostUrl { get; set; } = "http://localhost:5057"; + + + [JsonPropertyName("ForEndUrl")] + public string ForEndUrl { get; set; } = "http://localhost:5174"; +} + +public class HotKeyDefaultSettings +{ + [JsonPropertyName("DefaultModifiers")] + public string DefaultModifiers { get; set; } = "Alt"; + + [JsonPropertyName("DefaultKey")] + public string DefaultKey { get; set; } = "X"; + + [JsonPropertyName("DefaultIsEnabled")] + public bool DefaultIsEnabled { get; set; } = true; +} diff --git a/src/TodoList.Maui/Platforms/Windows/WindowsWindowService.cs b/src/TodoList.Maui/Platforms/Windows/WindowsWindowService.cs index 9cf6e0f..7d78c1b 100644 --- a/src/TodoList.Maui/Platforms/Windows/WindowsWindowService.cs +++ b/src/TodoList.Maui/Platforms/Windows/WindowsWindowService.cs @@ -1,10 +1,49 @@ +#if WINDOWS +using System.Runtime.InteropServices; using Microsoft.Maui.Controls; using Microsoft.UI.Windowing; +using WinRT.Interop; namespace TodoList.Maui.Platforms.Windows { public class WindowsWindowService { + private const int SW_HIDE = 0; + private const int SW_SHOW = 5; + private const int SW_RESTORE = 9; + + [DllImport("user32.dll", SetLastError = true)] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + public void HideWindow(Window window) + { + if (window == null) return; + + var platformWindow = window.Handler?.PlatformView; + if (platformWindow == null) return; + + var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow; + var hWnd = WindowNative.GetWindowHandle(nativeWindow); + if (hWnd == IntPtr.Zero) return; + + ShowWindow(hWnd, SW_HIDE); + } + + public void RestoreWindow(Window window) + { + if (window == null) return; + + var platformWindow = window.Handler?.PlatformView; + if (platformWindow == null) return; + + var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow; + var hWnd = WindowNative.GetWindowHandle(nativeWindow); + if (hWnd == IntPtr.Zero) return; + + ShowWindow(hWnd, SW_SHOW); + ShowWindow(hWnd, SW_RESTORE); + } + public void MinimizeWindow(Window window) { if (window == null) return; @@ -25,4 +64,5 @@ namespace TodoList.Maui.Platforms.Windows } } } -} \ No newline at end of file +} +#endif diff --git a/src/TodoList.Maui/README.md b/src/TodoList.Maui/README.md index c681c1b..5dd7f10 100644 --- a/src/TodoList.Maui/README.md +++ b/src/TodoList.Maui/README.md @@ -134,7 +134,7 @@ dotnet run -f net10.0-android 1. **macOS 权限**: 首次运行时需要在系统设置中授予辅助功能权限 2. **Windows UAC**: 某些情况下可能需要管理员权限 3. **移动端限制**: 移动端不支持真正的全局快捷键,使用通知快捷方式替代 -4. **WebView**: 确保 TodoList.Api 服务在 `http://localhost:5057` 运行 +4. **WebView**: 确保 TodoList.Api 服务在 `http://localhost:5173` 运行 ## 后续计划 diff --git a/src/TodoList.Maui/Resources/Images/app_titlebar_icon.svg b/src/TodoList.Maui/Resources/Images/app_titlebar_icon.svg new file mode 100644 index 0000000..a07ffc7 --- /dev/null +++ b/src/TodoList.Maui/Resources/Images/app_titlebar_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/TodoList.Maui/Resources/Images/titlebar_icon.png b/src/TodoList.Maui/Resources/Images/titlebar_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0fa8845448329366c22872fd3f9031b623dffa64 GIT binary patch literal 106636 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 Y0NbQ@elPb|cmMzZ07*qoM6N<$g0UGXnE(I) literal 0 HcmV?d00001 diff --git a/src/TodoList.Maui/Services/AppMetadata.cs b/src/TodoList.Maui/Services/AppMetadata.cs new file mode 100644 index 0000000..976b368 --- /dev/null +++ b/src/TodoList.Maui/Services/AppMetadata.cs @@ -0,0 +1,60 @@ +using Microsoft.Maui.ApplicationModel; +using System; +using System.Reflection; + +namespace TodoList.Maui.Services; + +public static class AppMetadata +{ + private const string AppNameText = "\u5F85\u529E\u4E8B\u9879"; + + public static string AppName => AppNameText; + + public static string? GetDisplayVersion() + { + // 优先使用Assembly版本 + var asmVersion = Assembly.GetExecutingAssembly().GetName().Version; + if (asmVersion != null) + { + // 只返回主版本.次版本.修订版本 (如: 1.0.4) + return $"{asmVersion.Major}.{asmVersion.Minor}.{asmVersion.Build}"; + } + + // 回退到AppInfo + var versionString = AppInfo.Current.VersionString?.Trim(); + if (string.IsNullOrWhiteSpace(versionString)) + { + return null; + } + + if (!Version.TryParse(versionString, out var parsed)) + { + return null; + } + + return $"{parsed.Major}.{parsed.Minor}.{parsed.Build}"; + } + + public static string GetDisplayTitle() + { + var version = GetDisplayVersion(); + return string.IsNullOrWhiteSpace(version) ? AppName : $"{AppName} v{version}"; + } + + public static string GetTitleBarVersionText() + { + var version = GetDisplayVersion(); + return string.IsNullOrWhiteSpace(version) ? AppNameText : $"{AppNameText} v{version}"; + } + + public static string GetWindowTitle() + { + return GetTitleBarVersionText(); + } + + public static string GetTrayTooltipText() + { + var text = GetDisplayTitle(); + return text.Length > 63 ? text[..63] : text; + } +} diff --git a/src/TodoList.Maui/Services/EmbeddedWebServerService.cs b/src/TodoList.Maui/Services/EmbeddedWebServerService.cs new file mode 100644 index 0000000..8d37b92 --- /dev/null +++ b/src/TodoList.Maui/Services/EmbeddedWebServerService.cs @@ -0,0 +1,138 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using System.Text.Json; +using TodoList.Application; +using TodoList.Application.DynamicApi; +using TodoList.Maui.Models; +using AppSettings = TodoList.Maui.Models.AppSettings; + +namespace TodoList.Maui.Services; + +public class EmbeddedWebServerService : IEmbeddedWebServerService +{ + private WebApplication? _webApp; + private readonly AppSettings _appSettings; + + public bool IsRunning => _webApp != null; + public string BaseUrl => _appSettings.WebServer.HostUrl; + + public EmbeddedWebServerService(AppSettings appSettings) + { + _appSettings = appSettings; + } + + public async Task StartAsync() + { + if (_webApp != null) return; + + var builder = WebApplication.CreateSlimBuilder(); + + builder.WebHost.UseUrls(_appSettings.WebServer.HostUrl); + + + builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + }); + + builder.Services.AddEndpointsApiExplorer(); + + + builder.Services.AddApplicationServices(_appSettings.WebServer.ConnectionString); + + builder.Services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + var app = builder.Build(); + + if (_appSettings.WebServer.IsUsingStatic) + { + ServeStaticFiles(app); + } + + app.UseCors("AllowAll"); + app.UseHttpsRedirection(); + app.UseAuthorization(); + app.UseDynamicApi(); + app.MapControllers(); + + _webApp = app; + + await _webApp.StartAsync(); + } + + private void ServeStaticFiles(WebApplication app) + { + var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot"); + + if (!Directory.Exists(wwwrootPath)) + { + Console.WriteLine("[EmbeddedWebServer] wwwroot directory not found. Static file serving disabled."); + return; + } + + try + { + var fileProvider = new PhysicalFileProvider(wwwrootPath); + var defaultFilesOptions = new DefaultFilesOptions { FileProvider = fileProvider, RequestPath = "" }; + app.UseDefaultFiles(defaultFilesOptions); + + var staticFileOptions = new StaticFileOptions + { + FileProvider = fileProvider, + RequestPath = "", + OnPrepareResponse = ctx => + { + ctx.Context.Response.Headers["Cache-Control"] = "no-cache, no-store, must-revalidate"; + ctx.Context.Response.Headers["Pragma"] = "no-cache"; + ctx.Context.Response.Headers["Expires"] = "0"; + } + }; + app.UseStaticFiles(staticFileOptions); + + app.Use(async (context, next) => + { + if (context.Request.Path.HasValue) + { + var path = context.Request.Path.Value; + if (path != "/" && !path.StartsWith("/assets", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/api", StringComparison.OrdinalIgnoreCase)) + { + var ext = Path.GetExtension(path); + if (string.IsNullOrEmpty(ext)) + { + context.Request.Path = "/index.html"; + } + } + } + await next(); + }); + + Console.WriteLine($"[EmbeddedWebServer] Serving static files from: {wwwrootPath}"); + } + catch (Exception ex) + { + Console.WriteLine($"[EmbeddedWebServer] Failed to serve static files: {ex.Message}"); + } + } + + public async Task StopAsync() + { + if (_webApp == null) return; + + await _webApp.StopAsync(); + await _webApp.DisposeAsync(); + _webApp = null; + } +} \ No newline at end of file diff --git a/src/TodoList.Maui/Services/HotKeySettingsService.cs b/src/TodoList.Maui/Services/HotKeySettingsService.cs index db1cf00..ba617fb 100644 --- a/src/TodoList.Maui/Services/HotKeySettingsService.cs +++ b/src/TodoList.Maui/Services/HotKeySettingsService.cs @@ -4,40 +4,23 @@ using TodoList.Maui.Models; namespace TodoList.Maui.Services { - /// - /// 热键设置服务接口 - /// public interface IHotKeySettingsService { - /// - /// 获取热键配置 - /// - /// 热键配置对象 HotKeyConfig GetConfig(); - - /// - /// 保存热键配置 - /// - /// 热键配置对象 void SaveConfig(HotKeyConfig config); - - /// - /// 重置为默认配置 - /// void ResetToDefault(); } - /// - /// 热键设置服务实现类,使用 Preferences API 持久化配置 - /// public class HotKeySettingsService : IHotKeySettingsService { private const string SettingsKey = "HotKeyConfig"; + private readonly AppSettings _appSettings; + + public HotKeySettingsService(AppSettings appSettings) + { + _appSettings = appSettings; + } - /// - /// 获取热键配置 - /// - /// 热键配置对象,如果不存在则返回默认配置 public HotKeyConfig GetConfig() { var json = Preferences.Get(SettingsKey, string.Empty); @@ -56,37 +39,26 @@ namespace TodoList.Maui.Services } } - /// - /// 保存热键配置 - /// - /// 热键配置对象 public void SaveConfig(HotKeyConfig config) { var json = JsonSerializer.Serialize(config); Preferences.Set(SettingsKey, json); } - /// - /// 重置为默认配置 - /// public void ResetToDefault() { var defaultConfig = GetDefaultConfig(); SaveConfig(defaultConfig); } - /// - /// 获取默认热键配置 - /// - /// 默认热键配置对象 private HotKeyConfig GetDefaultConfig() { return new HotKeyConfig { - Modifiers = "Alt", - Key = "X", - IsEnabled = true + Modifiers = _appSettings.HotKey.DefaultModifiers, + Key = _appSettings.HotKey.DefaultKey, + IsEnabled = _appSettings.HotKey.DefaultIsEnabled }; } } -} \ No newline at end of file +} diff --git a/src/TodoList.Maui/Services/IEmbeddedWebServerService.cs b/src/TodoList.Maui/Services/IEmbeddedWebServerService.cs new file mode 100644 index 0000000..2075fb7 --- /dev/null +++ b/src/TodoList.Maui/Services/IEmbeddedWebServerService.cs @@ -0,0 +1,9 @@ +namespace TodoList.Maui.Services; + +public interface IEmbeddedWebServerService +{ + bool IsRunning { get; } + string BaseUrl { get; } + Task StartAsync(); + Task StopAsync(); +} diff --git a/src/TodoList.Maui/Services/Platforms/WindowsGlobalHotKeyService.cs b/src/TodoList.Maui/Services/Platforms/WindowsGlobalHotKeyService.cs index 516cc33..d52646f 100644 --- a/src/TodoList.Maui/Services/Platforms/WindowsGlobalHotKeyService.cs +++ b/src/TodoList.Maui/Services/Platforms/WindowsGlobalHotKeyService.cs @@ -1,7 +1,5 @@ #if WINDOWS using System.Runtime.InteropServices; -using Microsoft.UI.Input; -using Microsoft.UI.Xaml.Input; using WinRT.Interop; using MauiWindow = Microsoft.Maui.Controls.Window; @@ -15,6 +13,7 @@ namespace TodoList.Maui.Services.Platforms { private const int HOTKEY_ID = 9000; private const int WM_HOTKEY = 0x0312; + private const int GWL_WNDPROC = -4; public const uint MOD_ALT = 0x0001; public const uint MOD_CONTROL = 0x0002; @@ -33,7 +32,8 @@ namespace TodoList.Maui.Services.Platforms private bool _isRegistered; private uint _currentModifiers; private uint _currentKey; - private IntPtr _windowHook; + private IntPtr _originalWndProc; + private WndProcDelegate? _wndProc; /// /// Windows 平台支持全局热键 @@ -47,13 +47,14 @@ namespace TodoList.Maui.Services.Platforms { if (_window == null) { - _window = Application.Current?.Windows.FirstOrDefault(); + _window = Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault(); if (_window == null) return; - - var nativeWindow = WindowNative.GetWindowHandle(_window); - _windowHandle = nativeWindow; } + if (_window.Handler?.PlatformView is not Microsoft.UI.Xaml.Window platformWindow) return; + _windowHandle = WindowNative.GetWindowHandle(platformWindow); + if (_windowHandle == IntPtr.Zero) return; + _callback = callback; _currentModifiers = ParseModifiers(modifiers); _currentKey = ParseKey(key); @@ -66,7 +67,7 @@ namespace TodoList.Maui.Services.Platforms if (RegisterHotKey(_windowHandle, HOTKEY_ID, _currentModifiers, _currentKey)) { _isRegistered = true; - SetupWindowHook(); + EnsureWndProcHook(); } else { @@ -81,14 +82,16 @@ namespace TodoList.Maui.Services.Platforms { if (_isRegistered) { - if (_windowHook != IntPtr.Zero) - { - UnhookWindowsHookEx(_windowHook); - _windowHook = IntPtr.Zero; - } UnregisterHotKey(_windowHandle, HOTKEY_ID); _isRegistered = false; } + + if (_originalWndProc != IntPtr.Zero && _windowHandle != IntPtr.Zero) + { + SetWindowProc(_windowHandle, GWL_WNDPROC, _originalWndProc); + _originalWndProc = IntPtr.Zero; + _wndProc = null; + } } /// @@ -102,29 +105,29 @@ namespace TodoList.Maui.Services.Platforms } } - /// - /// 设置窗口钩子以监听热键消息 - /// - private void SetupWindowHook() + private void EnsureWndProcHook() { - var moduleHandle = GetModuleHandle(string.Empty); - _windowHook = SetWindowsHookEx(WH_GETMESSAGE, HotKeyHookProc, moduleHandle, 0); + if (_originalWndProc != IntPtr.Zero) return; + if (_windowHandle == IntPtr.Zero) return; + + _wndProc = WndProc; + var newWndProcPtr = Marshal.GetFunctionPointerForDelegate(_wndProc); + _originalWndProc = SetWindowProc(_windowHandle, GWL_WNDPROC, newWndProcPtr); } - /// - /// 热键钩子回调函数 - /// - private IntPtr HotKeyHookProc(int nCode, IntPtr wParam, IntPtr lParam) + private IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { - if (nCode >= 0) + if (msg == WM_HOTKEY && wParam == (IntPtr)HOTKEY_ID) { - var msg = Marshal.PtrToStructure(lParam); - if (msg.message == WM_HOTKEY && msg.wParam == (IntPtr)HOTKEY_ID) - { - _callback?.Invoke(); - } + _callback?.Invoke(); } - return CallNextHookEx(_windowHook, nCode, wParam, lParam); + + if (_originalWndProc != IntPtr.Zero) + { + return CallWindowProc(_originalWndProc, hWnd, msg, wParam, lParam); + } + + return DefWindowProc(hWnd, msg, wParam, lParam); } /// @@ -161,39 +164,26 @@ namespace TodoList.Maui.Services.Platforms return 0x58; // Default 'X' } - [DllImport("user32.dll")] - private static extern IntPtr SetWindowsHookEx(int idHook, HotKeyHookProcDelegate lpfn, IntPtr hMod, uint dwThreadId); + private delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - [DllImport("user32.dll")] - private static extern bool UnhookWindowsHookEx(IntPtr hhk); + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static extern IntPtr SetWindowLongPtr64(IntPtr hWnd, int nIndex, IntPtr dwNewLong); - [DllImport("user32.dll")] - private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll", EntryPoint = "SetWindowLongW")] + private static extern IntPtr SetWindowLong32(IntPtr hWnd, int nIndex, IntPtr dwNewLong); - [DllImport("kernel32.dll")] - private static extern IntPtr GetModuleHandle(string lpModuleName); - - private delegate IntPtr HotKeyHookProcDelegate(int nCode, IntPtr wParam, IntPtr lParam); - - [StructLayout(LayoutKind.Sequential)] - private struct MSG + private static IntPtr SetWindowProc(IntPtr hWnd, int nIndex, IntPtr newProc) { - public IntPtr hwnd; - public uint message; - public IntPtr wParam; - public IntPtr lParam; - public uint time; - public POINT pt; + return IntPtr.Size == 8 + ? SetWindowLongPtr64(hWnd, nIndex, newProc) + : SetWindowLong32(hWnd, nIndex, newProc); } - [StructLayout(LayoutKind.Sequential)] - private struct POINT - { - public int x; - public int y; - } + [DllImport("user32.dll")] + private static extern IntPtr CallWindowProc(IntPtr lpPrevWndFunc, IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); - private const int WH_GETMESSAGE = 3; + [DllImport("user32.dll")] + private static extern IntPtr DefWindowProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); } } -#endif \ No newline at end of file +#endif diff --git a/src/TodoList.Maui/Services/Platforms/WindowsSystemTrayService.cs b/src/TodoList.Maui/Services/Platforms/WindowsSystemTrayService.cs index a07b105..c0bdfa0 100644 --- a/src/TodoList.Maui/Services/Platforms/WindowsSystemTrayService.cs +++ b/src/TodoList.Maui/Services/Platforms/WindowsSystemTrayService.cs @@ -23,12 +23,10 @@ namespace TodoList.Maui.Services.Platforms _onShowWindow = onShowWindow; _onExit = onExit; - _notifyIcon = new NotifyIcon - { - Visible = true, - Text = "TodoList", - Icon = GetAppIcon() - }; + _notifyIcon = new NotifyIcon(); + _notifyIcon.Icon = GetAppIcon(); + _notifyIcon.Text = GetNotifyIconText(); + _notifyIcon.Visible = true; _notifyIcon.DoubleClick += (s, e) => _onShowWindow?.Invoke(); @@ -76,6 +74,11 @@ namespace TodoList.Maui.Services.Platforms return SystemIcons.Application; } + + private static string GetNotifyIconText() + { + return AppMetadata.GetTrayTooltipText(); + } } } -#endif \ No newline at end of file +#endif diff --git a/src/TodoList.Maui/TodoList.Maui.csproj b/src/TodoList.Maui/TodoList.Maui.csproj index 5cd2397..1f6efc9 100644 --- a/src/TodoList.Maui/TodoList.Maui.csproj +++ b/src/TodoList.Maui/TodoList.Maui.csproj @@ -17,21 +17,28 @@ true enable enable + 65001 C:\Users\ShaoHua\AppData\Local\Android\Sdk $(AndroidSdkDirectory)\ndk\25.2.9519653 - TodoList.Maui + 待办事项 com.companyname.todolist.maui - 1.0.0 + 1.1.1.0 + $(Version) 1 - 1.0.0 + + + 待办事项 + 待办事项 + TodoList + Copyright 2024 None @@ -41,6 +48,10 @@ 21.0 10.0.17763.0 10.0.17763.0 + + $([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../TodoList.Web')) + $(TodoListWebDir)\dist + false @@ -52,6 +63,7 @@ + @@ -62,24 +74,92 @@ - - - - + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + <_TodoListMauiProjectWwwrootDir>$(MSBuildProjectDirectory)\wwwroot + + + + + <_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**" /> + + + + + + + + <_TodoListMauiOutWwwrootDir>$(OutDir)wwwroot + + + + + <_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**" /> + + + + + + + + <_TodoListMauiPublishWwwrootDir>$(PublishDir)wwwroot + + + + + <_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**" /> + + + + + + + diff --git a/src/TodoList.Maui/TodoList.Maui.csproj.DotSettings b/src/TodoList.Maui/TodoList.Maui.csproj.DotSettings new file mode 100644 index 0000000..2120a63 --- /dev/null +++ b/src/TodoList.Maui/TodoList.Maui.csproj.DotSettings @@ -0,0 +1,2 @@ + + False \ No newline at end of file diff --git a/src/TodoList.Maui/ViewModels/QuickEntryViewModel.cs b/src/TodoList.Maui/ViewModels/QuickEntryViewModel.cs deleted file mode 100644 index 10cfdd9..0000000 --- a/src/TodoList.Maui/ViewModels/QuickEntryViewModel.cs +++ /dev/null @@ -1,53 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using System; -using System.Collections.ObjectModel; -using TodoList.Maui.Models; - -namespace TodoList.Maui.ViewModels; - -public partial class QuickEntryViewModel : ObservableObject -{ - private readonly Action _closeAction; - - [ObservableProperty] - private string content = string.Empty; - - [ObservableProperty] - private string priority = "中"; - - public ObservableCollection Priorities { get; } = new ObservableCollection - { - "高", - "中", - "低" - }; - - public QuickEntryViewModel(Action closeAction) - { - _closeAction = closeAction; - } - - [RelayCommand] - private void Save() - { - if (string.IsNullOrWhiteSpace(Content)) return; - - var newTask = new TodoItem - { - Content = Content, - Priority = Priority, - IsCompleted = false - }; - - Content = string.Empty; - Priority = "中"; - _closeAction?.Invoke(); - } - - [RelayCommand] - private void Cancel() - { - _closeAction?.Invoke(); - } -} diff --git a/src/TodoList.Maui/Views/MainPage.xaml b/src/TodoList.Maui/Views/MainPage.xaml index c334dad..0bb1ed7 100644 --- a/src/TodoList.Maui/Views/MainPage.xaml +++ b/src/TodoList.Maui/Views/MainPage.xaml @@ -1,32 +1,8 @@ + x:Class="TodoList.Maui.Views.MainPage"> - - - - + - \ No newline at end of file + diff --git a/src/TodoList.Maui/Views/MainPage.xaml.cs b/src/TodoList.Maui/Views/MainPage.xaml.cs index d53ae1c..edec49a 100644 --- a/src/TodoList.Maui/Views/MainPage.xaml.cs +++ b/src/TodoList.Maui/Views/MainPage.xaml.cs @@ -6,7 +6,7 @@ namespace TodoList.Maui.Views { public partial class MainPage : ContentPage { - private readonly IHotKeySettingsService _settingsService; + private readonly AppSettings _appSettings; #if WINDOWS private Platforms.Windows.WindowsKeyboardHandler? _keyboardHandler; #endif @@ -14,12 +14,33 @@ namespace TodoList.Maui.Views public MainPage() { InitializeComponent(); - _settingsService = new HotKeySettingsService(); + _appSettings = Microsoft.Maui.Controls.Application.Current?.Handler?.MauiContext?.Services + .GetService() ?? new AppSettings(); + + SetupWebViewSource(); SetupWebViewCommunication(); SetupKeyboardHandler(); } - public const string Version = "1.0.0"; + + private void SetupWebViewSource() + { + + + if (_appSettings.WebServer.IsUsingStatic) + { + var webServer = Microsoft.Maui.Controls.Application.Current?.Handler?.MauiContext?.Services + .GetService(); + if (webServer is { IsRunning: true }) + { + MainWebView.Source = webServer.BaseUrl; + } + } + else + { + MainWebView.Source = _appSettings.WebServer.ForEndUrl; + } + } private void SetupKeyboardHandler() { @@ -32,7 +53,7 @@ namespace TodoList.Maui.Views private void OnEscKeyPressed(object? sender, EventArgs e) { - var window = Application.Current?.Windows.FirstOrDefault(); + var window = Microsoft.Maui.Controls.Application.Current?.Windows.FirstOrDefault(); if (window != null) { #if WINDOWS @@ -46,6 +67,10 @@ namespace TodoList.Maui.Views { MainWebView.Navigated += async (s, e) => { +#if !DEBUG + await MainWebView.EvaluateJavaScriptAsync($"window.__API_BASE_URL__ = '{_appSettings.WebServer.HostUrl}/api';"); +#endif + await MainWebView.EvaluateJavaScriptAsync(@" window.mauiInterop = { onHotKeyConfigUpdated: null, @@ -68,34 +93,6 @@ namespace TodoList.Maui.Views }); "); }; - - // MainWebView.WebMessageReceived += async (s, e) => - // { - // try - // { - // var message = e.Message; - // if (message.StartsWith("HOTKEY_CONFIG:")) - // { - // var configJson = message.Substring("HOTKEY_CONFIG:".Length); - // var config = System.Text.Json.JsonSerializer.Deserialize(configJson); - // if (config != null) - // { - // _settingsService.SaveConfig(config); - // } - // } - // } - // catch (Exception ex) - // { - // System.Diagnostics.Debug.WriteLine($"Error processing web message: {ex.Message}"); - // } - // }; - } - - private async void OnHotKeySettingsClicked(object sender, EventArgs e) - { - var config = _settingsService.GetConfig(); - var configJson = System.Text.Json.JsonSerializer.Serialize(config); - await MainWebView.EvaluateJavaScriptAsync($"window.mauiInterop.openHotKeySettings({configJson});"); } } -} \ No newline at end of file +} diff --git a/src/TodoList.Maui/Views/QuickEntryPage.xaml b/src/TodoList.Maui/Views/QuickEntryPage.xaml deleted file mode 100644 index b9b1ee5..0000000 --- a/src/TodoList.Maui/Views/QuickEntryPage.xaml +++ /dev/null @@ -1,46 +0,0 @@ - - - - -