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 a42517e..7ae1145 100644 Binary files a/src/TodoList.Api/todolist.db and b/src/TodoList.Host/todolist.db differ 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 0000000..0fa8845 Binary files /dev/null and b/src/TodoList.Maui/Resources/Images/titlebar_icon.png differ 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 @@ - - - - -