feat: 重构 TodoList 架构,新增动态 API 与 MAUI 内嵌 Web 服务

feat:优化交互逻辑,优化发布流程
This commit is contained in:
ShaoHua
2026-04-04 22:11:18 +08:00
parent 81c649cb23
commit 1412ce2695
91 changed files with 3612 additions and 2489 deletions
+3
View File
@@ -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,5 @@ MigrationBackup/
FodyWeavers.xsd
/Setup/Output
/TodoList/Output
/src/TodoList.Maui/Output
/src/TodoList.Host/todolist.db
+35
View File
@@ -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"
}
]
}
+41
View File
@@ -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"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\TodoList.Maui"
$ProjectFile = Join-Path $ProjectDir "TodoList.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
# Read version from project file
$currentVersion = "1.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
} else {
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
}
}
# Update setup script version with current version before build
if (Test-Path $SetupScript) {
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"'
$versionFound = $true
break
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent
}
}
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 "MAUI build failed"
exit 1
}
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
& $ISCC $SetupScript
if ($LASTEXITCODE -eq 0) {
Write-Host "Setup package created successfully!" -ForegroundColor Green
} else {
Write-Error "Packaging failed"
}
} 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 "<Version>.*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content
+1 -1
View File
@@ -64,7 +64,7 @@ dotnet restore
dotnet ef database update
dotnet run
```
API 将在 `http://localhost:5057` 启动
API 将在 `http://localhost:5173` 启动
#### 3. 启动前端 Web
```bash
-388
View File
@@ -1,388 +0,0 @@
# TodoList 服务管理脚本
本目录包含用于管理 TodoList 服务的 PowerShell 脚本。
## 脚本列表
### 1. `start-service.ps1` - 启动服务
启动 TodoList.Api 服务和 TodoList.Maui 应用。
#### 使用方法
```powershell
# 启动 API 服务和 MAUI 应用(默认)
.\start-service.ps1
# 只启动 API 服务
.\start-service.ps1 -StartMaui:$false
```
#### 功能
- 检查 TodoList.Api 服务是否已在运行
- 如果未运行,启动 TodoList.Api 服务
- 默认启动 TodoList.Maui 应用(可通过 `-StartMaui:$false` 禁用)
- 显示服务访问地址和 Swagger 文档链接
#### 输出示例
```
====================================
TodoList 服务启动脚本
====================================
[1/2] 检查 TodoList.Api 服务...
✓ TodoList.Api 服务未运行
[2/2] 启动 TodoList.Api 服务...
📂 工作目录: D:\Proj\TodoList\src\TodoList.Api
🚀 启动服务...
✅ TodoList.Api 服务已启动
进程 ID: 65992
访问地址: http://localhost:5057
Swagger 文档: http://localhost:5057/swagger
[3/3] 启动 TodoList.Maui 应用...
🚀 启动 TodoList.Maui...
✅ TodoList.Maui 已启动
====================================
启动完成
====================================
💡 提示:
- 按 Ctrl+C 可以停止服务
- 运行 stop-service.ps1 可以关闭服务
- 运行 restart-service.ps1 可以重启服务
```
---
### 2. `stop-service.ps1` - 关闭服务
停止所有正在运行的 TodoList.Api 服务和 TodoList.Maui 应用。
#### 使用方法
```powershell
# 正常关闭
.\stop-service.ps1
# 强制关闭
.\stop-service.ps1 -Force
```
#### 功能
- 查找并停止所有 TodoList.Api 进程
- 查找并停止所有 TodoList.Maui 进程
- 显示停止的进程数量和状态
#### 输出示例
```
====================================
TodoList 服务关闭脚本
====================================
[1/2] 查找 TodoList.Api 服务...
🔍 找到 1 个 TodoList.Api 进程
正在停止进程 ID: 65992...
✅ 进程 65992 已停止
[2/2] 查找 TodoList.Maui 应用...
✓ TodoList.Maui 应用未运行
====================================
关闭完成
已停止 1 个进程
====================================
💡 提示:
- 运行 start-service.ps1 可以启动服务
- 运行 restart-service.ps1 可以重启服务
```
---
### 3. `restart-service.ps1` - 重启服务
停止现有服务并重新启动。
#### 使用方法
```powershell
# 重启 API 服务
.\restart-service.ps1
# 重启 API 和 MAUI 应用
.\restart-service.ps1 -StartMaui
# 强制重启
.\restart-service.ps1 -Force
```
#### 功能
- 调用 stop-service.ps1 停止现有服务
- 等待进程完全关闭(最多 10 秒)
- 调用 start-service.ps1 启动服务
- 显示重启进度和状态
#### 输出示例
```
====================================
TodoList 服务重启脚本
====================================
[1/3] 停止现有服务...
✅ 服务已停止
[2/3] 等待进程完全关闭...
✅ 所有进程已关闭
[3/3] 启动服务...
✅ 服务已启动
====================================
重启完成
====================================
💡 提示:
- 按 Ctrl+C 可以停止服务
- 运行 stop-service.ps1 可以关闭服务
- 运行 restart-service.ps1 可以重启服务
```
---
### 4. `BuildSetup.ps1` - 构建安装包
构建 TodoList.Maui 项目的 Release 版本并创建 Inno Setup 安装包。
#### 使用方法
```powershell
# 在 TodoList 目录下运行
.\TodoList\BuildSetup.ps1
```
#### 功能
- 自动读取项目版本号
- 自动递增补丁版本号(例如 1.0.0 → 1.0.1
- 更新 .csproj 文件中的版本号
- 更新 setup.iss 文件中的版本号
- 构建 Release 版本(win-x64
- 使用 Inno Setup 编译器创建安装包
#### 输出示例
```
Setup package created successfully!
```
#### 依赖项
- .NET SDK
- Inno Setup 6(默认路径:`C:\Program Files (x86)\Inno Setup 6\ISCC.exe`
---
## 参数说明
### `start-service.ps1`
| 参数 | 类型 | 默认值 | 说明 |
|------|------|----------|------|
| `-StartMaui` | Switch | `$true` | 是否启动 TodoList.Maui 应用(默认启用) |
| `-ServicePath` | String | `"src\TodoList.Api"` | API 服务相对路径 |
| `-MauiPath` | String | `"src\TodoList.Maui"` | MAUI 应用相对路径 |
### `stop-service.ps1`
| 参数 | 类型 | 默认值 | 说明 |
|------|------|----------|------|
| `-Force` | Switch | `$false` | 是否强制关闭进程 |
### `restart-service.ps1`
| 参数 | 类型 | 默认值 | 说明 |
|------|------|----------|------|
| `-StartMaui` | Switch | `$false` | 是否同时启动 TodoList.Maui 应用 |
| `-Force` | Switch | `$false` | 是否强制关闭进程 |
### `BuildSetup.ps1`
| 参数 | 类型 | 默认值 | 说明 |
|------|------|----------|------|
| 无 | - | - | 脚本自动检测项目文件并处理 |
---
## 使用场景
### 场景 1: 首次启动
```powershell
# 启动 API 服务和 MAUI 应用(默认)
.\start-service.ps1
# 访问 http://localhost:5057 查看服务
# 访问 http://localhost:5057/swagger 查看 API 文档
```
### 场景 2: 只启动 API 服务
```powershell
# 只启动 API 服务,不启动 MAUI 应用
.\start-service.ps1 -StartMaui:$false
```
### 场景 3: 开发调试
```powershell
# 启动 API 和 MAUI 应用(默认行为)
.\start-service.ps1
# 使用 Alt+X 快捷键唤醒 MAUI 应用
# 在 MAUI 应用中测试快捷键功能
```
### 场景 4: 代码修改后重启
```powershell
# 快速重启服务
.\restart-service.ps1
# 重启服务并启动 MAUI 应用
.\restart-service.ps1 -StartMaui
# 或者强制重启(如果进程卡住)
.\restart-service.ps1 -Force
```
### 场景 5: 完全关闭
```powershell
# 关闭所有服务
.\stop-service.ps1
# 或者强制关闭
.\stop-service.ps1 -Force
```
### 场景 6: 构建安装包
```powershell
# 在 TodoList 目录下构建安装包
.\TodoList\BuildSetup.ps1
# 脚本会自动:
# 1. 递增版本号
# 2. 构建 Release 版本
# 3. 创建 Inno Setup 安装包
```
---
## 注意事项
1. **PowerShell 执行策略**
- 如果遇到执行策略错误,运行:`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
- 或者临时绕过:`powershell -ExecutionPolicy Bypass -File .\start-service.ps1`
2. **进程检测**
- 脚本通过进程名称和窗口标题检测 TodoList.Api 服务
- 脚本通过进程名称检测 TodoList.Maui 应用
3. **端口占用**
- 如果端口 5057 被占用,启动会失败
- 使用 `netstat -ano | findstr :5057` 检查端口占用情况
4. **MAUI 应用构建**
- 如果 MAUI 应用不存在,需要先构建:`dotnet build src\TodoList.Maui\TodoList.Maui.csproj`
- 默认路径:`src\TodoList.Maui\bin\Debug\net10.0-windows10.0.19041.0\win-x64\TodoList.Maui.exe`
5. **快捷键功能**
- MAUI 应用启动后,默认快捷键为 `Alt + X`
- 可以在应用设置中自定义快捷键
---
## 故障排除
### 问题 1: 无法启动服务
**症状**: 运行 `start-service.ps1` 后服务未启动
**解决方案**:
1. 检查 .NET SDK 是否安装:`dotnet --version`
2. 检查项目路径是否正确
3. 查看错误信息:`dotnet run src\TodoList.Api\TodoList.Api.csproj`
### 问题 2: 无法停止服务
**症状**: 运行 `stop-service.ps1` 后进程仍在运行
**解决方案**:
1. 使用强制关闭:`.\stop-service.ps1 -Force`
2. 手动结束进程:`taskkill /F /IM dotnet.exe`
3. 检查是否有其他 dotnet 进程占用
### 问题 3: MAUI 应用无法启动
**症状**: 运行 `start-service.ps1` 后 MAUI 应用未启动
**解决方案**:
1. 先构建 MAUI 项目:`dotnet build src\TodoList.Maui\TodoList.Maui.csproj`
2. 检查可执行文件是否存在
3. 查看构建错误信息
### 问题 4: BuildSetup.ps1 无法构建安装包
**症状**: 运行 `.\TodoList\BuildSetup.ps1` 后构建失败
**解决方案**:
1. 检查 Inno Setup 是否已安装:`Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"`
2. 如果未安装,请从 https://jrsoftware.org/isdl.php 下载安装
3. 检查 .NET SDK 是否安装:`dotnet --version`
4. 检查项目文件是否存在:`Test-Path .\TodoList\TodoList.csproj`
5. 检查 setup.iss 文件是否存在:`Test-Path .\TodoList\setup.iss`
### 问题 5: 版本号未正确递增
**症状**: 运行 BuildSetup.ps1 后版本号未变化
**解决方案**:
1. 检查 .csproj 文件中是否有 `<Version>` 标签
2. 确保版本号格式为 `X.Y.Z`(三个数字用点分隔)
3. 手动检查并修复版本号格式
---
## 快捷命令
```powershell
# 启动服务(API + MAUI,默认)
.\start-service.ps1
# 只启动 API 服务
.\start-service.ps1 -StartMaui:$false
# 关闭服务
.\stop-service.ps1
# 重启服务
.\restart-service.ps1
# 重启服务并启动 MAUI 应用
.\restart-service.ps1 -StartMaui
# 强制关闭
.\stop-service.ps1 -Force
# 强制重启
.\restart-service.ps1 -Force
# 构建安装包
.\TodoList\BuildSetup.ps1
```
---
## 技术细节
- **脚本语言**: PowerShell 5.1+
- **目标平台**: Windows
- **依赖**: .NET SDK, dotnet CLI
- **错误处理**: 支持错误捕获和友好提示
- **日志输出**: 彩色输出,易于阅读
---
## 更新日志
### v1.1.0 (2026-03-18)
- 新增 `BuildSetup.ps1` 脚本,支持自动构建安装包
- 更新 `start-service.ps1`,默认启动 MAUI 应用(`-StartMaui` 默认值为 `$true`
- 优化所有脚本的输出格式,添加提示信息
- 更新文档,修正参数默认值说明
- 添加 BuildSetup.ps1 相关故障排除指南
### v1.0.0 (2026-03-13)
- 初始版本
- 实现启动、关闭、重启服务功能
- 支持 TodoList.Api 和 TodoList.Maui 应用管理
- 添加参数支持和错误处理
- 彩色输出和友好提示
+2 -1
View File
@@ -6,7 +6,8 @@
<Platform Name="x86" />
</Configurations>
<Project Path="TodoList/TodoList.csproj" />
<Project Path="src/TodoList.Api/TodoList.Api.csproj" />
<Project Path="src/TodoList.Application/TodoList.Application.csproj" />
<Project Path="src/TodoList.Core/TodoList.Core.csproj" />
<Project Path="src/TodoList.Host/TodoList.Host.csproj" />
<Project Path="src/TodoList.Maui/TodoList.Maui.csproj" />
</Solution>
+38 -6
View File
@@ -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<AssemblyInformationalVersionAttribute>()?.InformationalVersion?.Trim();
if (!string.IsNullOrWhiteSpace(info))
{
var plus = info.IndexOf('+');
return plus >= 0 ? info[..plus] : info;
}
var file = asm.GetCustomAttribute<AssemblyFileVersionAttribute>()?.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)
+1
View File
@@ -5,6 +5,7 @@
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<CodePage>65001</CodePage>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>icon.ico</ApplicationIcon>
-2
View File
@@ -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
+1 -1
View File
@@ -114,7 +114,7 @@
</Button.Template>
</Button>
<CheckBox Grid.Column="3" Content="显示已完成"
<CheckBox Grid.Column="3" Content="已完成"
IsChecked="{Binding ShowCompleted}"
VerticalAlignment="Center" Foreground="#555" FontSize="11"/>
</Grid>
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -182,7 +182,7 @@
## 当前运行状态
### 服务状态
-**TodoList.Api**: 运行中 (http://localhost:5057)
-**TodoList.Api**: 运行中 (http://localhost:5173)
-**TodoList.Web**: 运行中 (http://localhost:5173)
-**TodoList.Maui**: 运行中 (Windows 桌面应用)
-92
View File
@@ -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 ""
+101
View File
@@ -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
@@ -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;
/// <summary>
/// 任务控制器,提供任务的 RESTful API 接口
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
private readonly ITaskService _taskService;
/// <summary>
/// 构造函数,注入任务服务
/// </summary>
/// <param name="taskService">任务服务接口</param>
public TasksController(ITaskService taskService)
{
_taskService = taskService;
}
/// <summary>
/// 获取任务列表
/// </summary>
/// <param name="completed">可选参数,true 获取已完成任务,false 获取未完成任务,不传则获取所有任务</param>
/// <returns>任务列表响应</returns>
[HttpGet]
public async Task<ActionResult<ApiResponse<List<TaskDto>>>> GetTasks([FromQuery] bool? completed = null)
{
try
{
List<TodoTask> 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<List<TaskDto>>
{
Success = true,
Data = taskDtos,
Message = "获取任务列表成功"
});
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<List<TaskDto>>
{
Success = false,
Message = "获取任务列表失败",
Errors = new List<string> { ex.Message }
});
}
}
/// <summary>
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>任务详情响应</returns>
[HttpGet("{id}")]
public async Task<ActionResult<ApiResponse<TaskDto>>> GetTask(int id)
{
try
{
var task = await _taskService.GetTaskByIdAsync(id);
if (task == null)
{
return NotFound(new ApiResponse<TaskDto>
{
Success = false,
Message = $"未找到ID为 {id} 的任务"
});
}
return Ok(new ApiResponse<TaskDto>
{
Success = true,
Data = MapToDto(task),
Message = "获取任务成功"
});
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<TaskDto>
{
Success = false,
Message = "获取任务失败",
Errors = new List<string> { ex.Message }
});
}
}
/// <summary>
/// 创建新任务
/// </summary>
/// <param name="dto">创建任务的数据传输对象</param>
/// <returns>创建的任务响应</returns>
[HttpPost]
public async Task<ActionResult<ApiResponse<TaskDto>>> CreateTask([FromBody] CreateTaskDto dto)
{
try
{
if (string.IsNullOrWhiteSpace(dto.Title))
{
return BadRequest(new ApiResponse<TaskDto>
{
Success = false,
Message = "任务标题不能为空",
Errors = new List<string> { "Title is required" }
});
}
var task = await _taskService.CreateTaskAsync(dto.Title, dto.Priority, dto.ParentTaskId);
return CreatedAtAction(nameof(GetTask), new { id = task.Id }, new ApiResponse<TaskDto>
{
Success = true,
Data = MapToDto(task),
Message = "创建任务成功"
});
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<TaskDto>
{
Success = false,
Message = "创建任务失败",
Errors = new List<string> { ex.Message }
});
}
}
/// <summary>
/// 更新任务
/// </summary>
/// <param name="id">任务ID</param>
/// <param name="dto">更新任务的数据传输对象</param>
/// <returns>更新后的任务响应</returns>
[HttpPut("{id}")]
public async Task<ActionResult<ApiResponse<TaskDto>>> UpdateTask(int id, [FromBody] UpdateTaskDto dto)
{
try
{
var task = await _taskService.UpdateTaskAsync(id, dto.Title, dto.Priority);
return Ok(new ApiResponse<TaskDto>
{
Success = true,
Data = MapToDto(task),
Message = "更新任务成功"
});
}
catch (KeyNotFoundException)
{
return NotFound(new ApiResponse<TaskDto>
{
Success = false,
Message = $"未找到ID为 {id} 的任务"
});
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<TaskDto>
{
Success = false,
Message = "更新任务失败",
Errors = new List<string> { ex.Message }
});
}
}
/// <summary>
/// 切换任务的完成状态
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>更新后的任务响应</returns>
[HttpPatch("{id}/complete")]
public async Task<ActionResult<ApiResponse<TaskDto>>> ToggleComplete(int id)
{
try
{
var task = await _taskService.ToggleCompleteAsync(id);
return Ok(new ApiResponse<TaskDto>
{
Success = true,
Data = MapToDto(task),
Message = task.IsCompleted ? "任务已完成" : "任务已取消完成"
});
}
catch (KeyNotFoundException)
{
return NotFound(new ApiResponse<TaskDto>
{
Success = false,
Message = $"未找到ID为 {id} 的任务"
});
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<TaskDto>
{
Success = false,
Message = "更新任务状态失败",
Errors = new List<string> { ex.Message }
});
}
}
/// <summary>
/// 删除任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>删除结果响应</returns>
[HttpDelete("{id}")]
public async Task<ActionResult<ApiResponse<object>>> DeleteTask(int id)
{
try
{
await _taskService.DeleteTaskAsync(id);
return Ok(new ApiResponse<object>
{
Success = true,
Message = "删除任务成功"
});
}
catch (KeyNotFoundException)
{
return NotFound(new ApiResponse<object>
{
Success = false,
Message = $"未找到ID为 {id} 的任务"
});
}
catch (Exception ex)
{
return StatusCode(500, new ApiResponse<object>
{
Success = false,
Message = "删除任务失败",
Errors = new List<string> { ex.Message }
});
}
}
/// <summary>
/// 将任务实体映射为数据传输对象
/// </summary>
/// <param name="task">任务实体</param>
/// <returns>任务数据传输对象</returns>
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<TaskDto>()
}).ToList() ?? new List<TaskDto>()
};
}
}
-117
View File
@@ -1,117 +0,0 @@
using TodoList.Core.Entities;
using System.Text.Json.Serialization;
namespace TodoList.Api.Models;
/// <summary>
/// 创建任务的数据传输对象
/// </summary>
public class CreateTaskDto
{
/// <summary>
/// 任务标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 任务优先级
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 父任务ID(可选)
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 更新任务的数据传输对象
/// </summary>
public class UpdateTaskDto
{
/// <summary>
/// 新的任务标题(可选)
/// </summary>
public string? Title { get; set; }
/// <summary>
/// 新的任务优先级(可选)
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; }
}
/// <summary>
/// 任务数据传输对象
/// </summary>
public class TaskDto
{
/// <summary>
/// 任务ID
/// </summary>
public int Id { get; set; }
/// <summary>
/// 任务标题
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 任务优先级
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; }
/// <summary>
/// 任务是否已完成
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 任务创建时间
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 任务最后更新时间
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 父任务ID
/// </summary>
public int? ParentTaskId { get; set; }
/// <summary>
/// 子任务列表
/// </summary>
public List<TaskDto> SubTasks { get; set; } = new();
}
/// <summary>
/// API统一响应格式
/// </summary>
/// <typeparam name="T">响应数据类型</typeparam>
public class ApiResponse<T>
{
/// <summary>
/// 请求是否成功
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 响应数据
/// </summary>
public T? Data { get; set; }
/// <summary>
/// 响应消息
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// 错误信息列表
/// </summary>
public List<string> Errors { get; set; } = new();
}
@@ -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;
/// <summary>
/// 任务仓储实现类,使用 Entity Framework Core 进行数据访问
/// </summary>
public class TaskRepository : ITaskRepository
{
private readonly TodoDbContext _context;
/// <summary>
/// 构造函数,注入数据库上下文
/// </summary>
/// <param name="context">数据库上下文</param>
public TaskRepository(TodoDbContext context)
{
_context = context;
}
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>任务列表</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetAllAsync()
{
return await _context.Tasks
.Include(t => t.SubTasks)
.ToListAsync();
}
/// <summary>
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>任务对象,如果不存在则返回null</returns>
public async System.Threading.Tasks.Task<TodoTask?> GetByIdAsync(int id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
}
/// <summary>
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务列表,按创建时间降序排列</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
/// <summary>
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务列表,按更新时间降序排列</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
/// <summary>
/// 添加新任务
/// </summary>
/// <param name="task">要添加的任务对象</param>
/// <returns>添加后的任务对象(包含生成的ID</returns>
public async System.Threading.Tasks.Task<TodoTask> AddAsync(TodoTask task)
{
_context.Tasks.Add(task);
await _context.SaveChangesAsync();
return task;
}
/// <summary>
/// 更新任务
/// </summary>
/// <param name="task">要更新的任务对象</param>
/// <returns>更新后的任务对象</returns>
public async System.Threading.Tasks.Task<TodoTask> UpdateAsync(TodoTask task)
{
task.UpdatedAt = DateTime.UtcNow;
_context.Tasks.Update(task);
await _context.SaveChangesAsync();
return task;
}
/// <summary>
/// 删除指定ID的任务
/// </summary>
/// <param name="id">任务ID</param>
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();
}
}
/// <summary>
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务ID</param>
/// <returns>子任务列表,按创建时间降序排列</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
}
-185
View File
@@ -1,185 +0,0 @@
using TodoTask = TodoList.Core.Entities.Task;
using TodoList.Core.Entities;
using TodoList.Core.Interfaces;
namespace TodoList.Api.Services;
/// <summary>
/// 任务服务实现类,提供任务相关的业务逻辑
/// </summary>
public class TaskService : ITaskService
{
private readonly ITaskRepository _repository;
/// <summary>
/// 构造函数,注入任务仓储
/// </summary>
/// <param name="repository">任务仓储接口</param>
public TaskService(ITaskRepository repository)
{
_repository = repository;
}
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>任务列表</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetAllTasksAsync()
{
return await _repository.GetAllAsync();
}
/// <summary>
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>任务对象,如果不存在则返回null</returns>
public async System.Threading.Tasks.Task<TodoTask?> GetTaskByIdAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
/// <summary>
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务列表</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetActiveTasksAsync()
{
return await _repository.GetActiveTasksAsync();
}
/// <summary>
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务列表</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetCompletedTasksAsync()
{
return await _repository.GetCompletedTasksAsync();
}
/// <summary>
/// 创建新任务
/// </summary>
/// <param name="title">任务标题</param>
/// <param name="priority">任务优先级</param>
/// <param name="parentTaskId">父任务ID(可选)</param>
/// <returns>创建的任务对象</returns>
/// <exception cref="ArgumentException">当任务标题为空时抛出</exception>
public async System.Threading.Tasks.Task<TodoTask> 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);
}
/// <summary>
/// 更新任务
/// </summary>
/// <param name="id">任务ID</param>
/// <param name="title">新的任务标题(可选)</param>
/// <param name="priority">新的任务优先级(可选)</param>
/// <returns>更新后的任务对象</returns>
/// <exception cref="KeyNotFoundException">当任务不存在时抛出</exception>
public async System.Threading.Tasks.Task<TodoTask> 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);
}
/// <summary>
/// 切换任务的完成状态
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>更新后的任务对象</returns>
/// <exception cref="KeyNotFoundException">当任务不存在时抛出</exception>
public async System.Threading.Tasks.Task<TodoTask> 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;
}
/// <summary>
/// 递归标记所有子任务为已完成
/// </summary>
/// <param name="parentTaskId">父任务ID</param>
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);
}
}
}
/// <summary>
/// 删除指定ID的任务
/// </summary>
/// <param name="id">任务ID</param>
/// <exception cref="KeyNotFoundException">当任务不存在时抛出</exception>
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);
}
/// <summary>
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务ID</param>
/// <returns>子任务列表</returns>
public async System.Threading.Tasks.Task<List<TodoTask>> GetSubTasksAsync(int parentTaskId)
{
return await _repository.GetSubTasksAsync(parentTaskId);
}
}
-6
View File
@@ -1,6 +0,0 @@
@TodoList.Api_HostAddress = http://localhost:5057
GET {{TodoList.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -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;
/// <summary>
/// 待办事项数据库上下文类,用于 Entity Framework Core 数据访问
/// </summary>
public class TodoDbContext : DbContext
{
/// <summary>
/// 构造函数,初始化数据库上下文
/// </summary>
/// <param name="options">数据库上下文选项</param>
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
/// <summary>
/// 任务数据集
/// </summary>
public DbSet<TodoTask> Tasks { get; set; }
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 配置模型关系和约束
/// </summary>
/// <param name="modelBuilder">模型构建器</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TodoTask>(entity =>
modelBuilder.Entity<TaskEntity>(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')");
@@ -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<DynamicApiMiddleware>();
}
}
@@ -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<string, Type> _serviceTypeCache = new Dictionary<string, Type>(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<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private bool IsRemoteServiceEnabled(MethodInfo method)
{
var attribute = method.GetCustomAttribute<RemoteServiceAttribute>();
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<HttpGetAttribute>() != null)
return httpMethod == "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null)
return httpMethod == "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null)
return httpMethod == "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null)
return httpMethod == "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != 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<HttpGetAttribute>();
var httpPostAttr = method.GetCustomAttribute<HttpPostAttribute>();
var httpPutAttr = method.GetCustomAttribute<HttpPutAttribute>();
var httpDeleteAttr = method.GetCustomAttribute<HttpDeleteAttribute>();
var httpPatchAttr = method.GetCustomAttribute<HttpPatchAttribute>();
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<object?> InvokeMethod(object service, MethodInfo method, HttpContext context)
{
var parameters = method.GetParameters();
var args = new List<object?>();
foreach (var param in parameters)
{
var fromBodyAttr = param.GetCustomAttribute<FromBodyAttribute>();
var fromQueryAttr = param.GetCustomAttribute<FromQueryAttribute>();
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<object?> 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<string>();
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);
}
}
@@ -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;
}
}
@@ -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
{
}
@@ -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;
}
@@ -0,0 +1,5 @@
namespace TodoList.Application.Interfaces;
public interface IDynamicApiService
{
}
@@ -0,0 +1,17 @@
using TodoList.Application.DynamicApi;
using TodoList.Application.Models;
namespace TodoList.Application.Interfaces;
public interface ITaskService : IDynamicApiService
{
Task<List<TaskDto>> GetAllTasksAsync();
Task<TaskDto?> GetTaskByIdAsync(int id);
Task<List<TaskDto>> GetActiveTasksAsync();
Task<List<TaskDto>> GetCompletedTasksAsync();
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
Task<TaskDto> ToggleCompleteAsync(int id);
Task DeleteTaskAsync(int id);
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
}
@@ -1,14 +1,14 @@
// <auto-generated />
// <auto-generated />
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<int>("Id")
.ValueGeneratedOnAdd()
@@ -1,9 +1,9 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TodoList.Api.Migrations
namespace TodoList.Application.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
@@ -1,14 +1,14 @@
// <auto-generated />
// <auto-generated />
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<int>("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");
});
@@ -1,8 +1,8 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TodoList.Api.Migrations
namespace TodoList.Application.Migrations
{
/// <inheritdoc />
public partial class AddParentTaskId : Migration
@@ -1,13 +1,13 @@
// <auto-generated />
// <auto-generated />
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<int>("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");
});
@@ -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<TaskDto> SubTasks { get; set; } = new();
}
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string Message { get; set; } = string.Empty;
public List<string> Errors { get; set; } = new();
}
@@ -0,0 +1,12 @@
{
"profiles": {
"TodoList.Application": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53852;http://localhost:53853"
}
}
}
@@ -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<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Include(t => t.SubTasks)
.ToListAsync();
}
public async Task<TaskEntity?> GetByIdAsync(int id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
public async Task<TaskEntity> 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<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
}
@@ -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<TodoDbContext>(options =>
options.UseSqlite(connectionString, b => b.MigrationsAssembly("TodoList.Application")));
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<ITaskService, TaskService>();
return services;
}
}
@@ -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<List<TaskDto>> GetAllTasksAsync()
{
var tasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList();
}
public async Task<TaskDto?> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null;
}
public async Task<List<TaskDto>> GetActiveTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => !t.IsCompleted).Select(MapToDto).ToList();
}
public async Task<List<TaskDto>> GetCompletedTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.IsCompleted).Select(MapToDto).ToList();
}
public async Task<TaskDto> 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<TaskDto> 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<TaskDto> 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<List<TaskDto>> 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()
};
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TodoList.Core\TodoList.Core.csproj" />
</ItemGroup>
</Project>
-6
View File
@@ -1,6 +0,0 @@
namespace TodoList.Core;
public class Class1
{
}
@@ -3,7 +3,7 @@ namespace TodoList.Core.Entities;
/// <summary>
/// 任务实体类,表示一个待办事项
/// </summary>
public class Task
public class TaskEntity
{
/// <summary>
/// 任务唯一标识符
@@ -43,10 +43,10 @@ public class Task
/// <summary>
/// 父任务导航属性
/// </summary>
public Task? ParentTask { get; set; }
public TaskEntity? ParentTask { get; set; }
/// <summary>
/// 子任务集合
/// </summary>
public List<Task> SubTasks { get; set; } = new();
public List<TaskEntity> SubTasks { get; set; } = new();
}
+10 -10
View File
@@ -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
/// 获取所有任务
/// </summary>
/// <returns>任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetAllAsync();
System.Threading.Tasks.Task<List<TaskEntity>> GetAllAsync();
/// <summary>
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>任务对象,如果不存在则返回null</returns>
System.Threading.Tasks.Task<TodoTask?> GetByIdAsync(int id);
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(int id);
/// <summary>
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetActiveTasksAsync();
System.Threading.Tasks.Task<List<TaskEntity>> GetActiveTasksAsync();
/// <summary>
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetCompletedTasksAsync();
System.Threading.Tasks.Task<List<TaskEntity>> GetCompletedTasksAsync();
/// <summary>
/// 添加新任务
/// </summary>
/// <param name="task">要添加的任务对象</param>
/// <param name="taskEntity">要添加的任务对象</param>
/// <returns>添加后的任务对象(包含生成的ID</returns>
System.Threading.Tasks.Task<TodoTask> AddAsync(TodoTask task);
System.Threading.Tasks.Task<TaskEntity> AddAsync(TaskEntity taskEntity);
/// <summary>
/// 更新任务
/// </summary>
/// <param name="task">要更新的任务对象</param>
/// <param name="taskEntity">要更新的任务对象</param>
/// <returns>更新后的任务对象</returns>
System.Threading.Tasks.Task<TodoTask> UpdateAsync(TodoTask task);
System.Threading.Tasks.Task<TaskEntity> UpdateAsync(TaskEntity taskEntity);
/// <summary>
/// 删除指定ID的任务
@@ -57,5 +57,5 @@ public interface ITaskRepository
/// </summary>
/// <param name="parentTaskId">父任务ID</param>
/// <returns>子任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetSubTasksAsync(int parentTaskId);
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId);
}
@@ -1,73 +0,0 @@
using TodoTask = TodoList.Core.Entities.Task;
using TodoList.Core.Entities;
namespace TodoList.Core.Interfaces;
/// <summary>
/// 任务服务接口,定义任务业务逻辑操作
/// </summary>
public interface ITaskService
{
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetAllTasksAsync();
/// <summary>
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>任务对象,如果不存在则返回null</returns>
System.Threading.Tasks.Task<TodoTask?> GetTaskByIdAsync(int id);
/// <summary>
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetActiveTasksAsync();
/// <summary>
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetCompletedTasksAsync();
/// <summary>
/// 创建新任务
/// </summary>
/// <param name="title">任务标题</param>
/// <param name="priority">任务优先级</param>
/// <param name="parentTaskId">父任务ID(可选)</param>
/// <returns>创建的任务对象</returns>
System.Threading.Tasks.Task<TodoTask> CreateTaskAsync(string title, TaskPriority priority, int? parentTaskId = null);
/// <summary>
/// 更新任务
/// </summary>
/// <param name="id">任务ID</param>
/// <param name="title">新的任务标题(可选)</param>
/// <param name="priority">新的任务优先级(可选)</param>
/// <returns>更新后的任务对象</returns>
System.Threading.Tasks.Task<TodoTask> UpdateTaskAsync(int id, string? title = null, TaskPriority? priority = null);
/// <summary>
/// 切换任务的完成状态
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>更新后的任务对象</returns>
System.Threading.Tasks.Task<TodoTask> ToggleCompleteAsync(int id);
/// <summary>
/// 删除指定ID的任务
/// </summary>
/// <param name="id">任务ID</param>
System.Threading.Tasks.Task DeleteTaskAsync(int id);
/// <summary>
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务ID</param>
/// <returns>子任务列表</returns>
System.Threading.Tasks.Task<List<TodoTask>> GetSubTasksAsync(int parentTaskId);
}
+3 -1
View File
@@ -1,9 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
@@ -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<TodoDbContext>(options =>
options.UseSqlite("Data Source=todolist.db"));
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<ITaskService, TaskService>();
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<TodoList.Application.Data.TodoDbContext>();
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();
@@ -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"
}
@@ -7,17 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.5" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TodoList.Core\TodoList.Core.csproj" />
<ProjectReference Include="..\TodoList.Application\TodoList.Application.csproj" />
</ItemGroup>
</Project>
+192 -38
View File
@@ -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<ISystemTrayService>();
}
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
}
}
[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
}
-2
View File
@@ -9,7 +9,5 @@
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate views:MainPage}"
Route="MainPage" />
</Shell>
-55
View File
@@ -1,55 +0,0 @@
$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) {
$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 "<ApplicationDisplayVersion>.*</ApplicationDisplayVersion>", "<ApplicationDisplayVersion>$newVersion</ApplicationDisplayVersion>"
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 + '"'
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
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed"
exit 1
}
# Package
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
& $ISCC $SetupScript
if ($LASTEXITCODE -eq 0) {
Write-Host "Setup package created successfully!" -ForegroundColor Green
} else {
Write-Error "Packaging failed"
}
} else {
Write-Error "Inno Setup compiler not found"
}
+78 -5
View File
@@ -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<IHotKeySettingsService, HotKeySettingsService>();
builder.Services.AddSingleton<IGlobalHotKeyService>(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<IHotKeySettingsService>(sp =>
new HotKeySettingsService(sp.GetRequiredService<AppSettings>()));
builder.Services.AddSingleton<IGlobalHotKeyService>(sp => GlobalHotKeyServiceFactory.Create());
builder.Services.AddSingleton<ISystemTrayService>(sp =>
{
#if WINDOWS
return new NullSystemTrayService();
return new WindowsSystemTrayService();
#else
return new NullSystemTrayService();
#endif
});
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
#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<TodoDbContext>();
// 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<TodoDbContext>();
context.Database.EnsureCreated();
}
}
var webServer = app.Services.GetRequiredService<IEmbeddedWebServerService>();
_ = 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<AppSettings>(json) ?? new AppSettings();
}
}
+44
View File
@@ -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;
}
@@ -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
}
}
}
}
}
#endif
+1 -1
View File
@@ -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` 运行
## 后续计划
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" rx="88" ry="88" fill="#512BD4" />
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" fill="#ffffff" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" fill="#ffffff" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" fill="#ffffff" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+60
View File
@@ -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;
}
}
@@ -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;
}
}
@@ -4,40 +4,23 @@ using TodoList.Maui.Models;
namespace TodoList.Maui.Services
{
/// <summary>
/// 热键设置服务接口
/// </summary>
public interface IHotKeySettingsService
{
/// <summary>
/// 获取热键配置
/// </summary>
/// <returns>热键配置对象</returns>
HotKeyConfig GetConfig();
/// <summary>
/// 保存热键配置
/// </summary>
/// <param name="config">热键配置对象</param>
void SaveConfig(HotKeyConfig config);
/// <summary>
/// 重置为默认配置
/// </summary>
void ResetToDefault();
}
/// <summary>
/// 热键设置服务实现类,使用 Preferences API 持久化配置
/// </summary>
public class HotKeySettingsService : IHotKeySettingsService
{
private const string SettingsKey = "HotKeyConfig";
private readonly AppSettings _appSettings;
public HotKeySettingsService(AppSettings appSettings)
{
_appSettings = appSettings;
}
/// <summary>
/// 获取热键配置
/// </summary>
/// <returns>热键配置对象,如果不存在则返回默认配置</returns>
public HotKeyConfig GetConfig()
{
var json = Preferences.Get(SettingsKey, string.Empty);
@@ -56,37 +39,26 @@ namespace TodoList.Maui.Services
}
}
/// <summary>
/// 保存热键配置
/// </summary>
/// <param name="config">热键配置对象</param>
public void SaveConfig(HotKeyConfig config)
{
var json = JsonSerializer.Serialize(config);
Preferences.Set(SettingsKey, json);
}
/// <summary>
/// 重置为默认配置
/// </summary>
public void ResetToDefault()
{
var defaultConfig = GetDefaultConfig();
SaveConfig(defaultConfig);
}
/// <summary>
/// 获取默认热键配置
/// </summary>
/// <returns>默认热键配置对象</returns>
private HotKeyConfig GetDefaultConfig()
{
return new HotKeyConfig
{
Modifiers = "Alt",
Key = "X",
IsEnabled = true
Modifiers = _appSettings.HotKey.DefaultModifiers,
Key = _appSettings.HotKey.DefaultKey,
IsEnabled = _appSettings.HotKey.DefaultIsEnabled
};
}
}
}
}
@@ -0,0 +1,9 @@
namespace TodoList.Maui.Services;
public interface IEmbeddedWebServerService
{
bool IsRunning { get; }
string BaseUrl { get; }
Task StartAsync();
Task StopAsync();
}
@@ -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;
/// <summary>
/// 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;
}
}
/// <summary>
@@ -102,29 +105,29 @@ namespace TodoList.Maui.Services.Platforms
}
}
/// <summary>
/// 设置窗口钩子以监听热键消息
/// </summary>
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);
}
/// <summary>
/// 热键钩子回调函数
/// </summary>
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<MSG>(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);
}
/// <summary>
@@ -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
#endif
@@ -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
#endif
+72 -10
View File
@@ -17,21 +17,28 @@
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CodePage>65001</CodePage>
<!-- Android SDK Path -->
<AndroidSdkDirectory>C:\Users\ShaoHua\AppData\Local\Android\Sdk</AndroidSdkDirectory>
<AndroidNdkDirectory>$(AndroidSdkDirectory)\ndk\25.2.9519653</AndroidNdkDirectory>
<!-- Display name -->
<ApplicationTitle>TodoList.Maui</ApplicationTitle>
<ApplicationTitle>待办事项</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.todolist.maui</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<Version>1.1.5</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<Version>1.0.0</Version>
<!-- Assembly Info -->
<AssemblyTitle>待办事项</AssemblyTitle>
<AssemblyProduct>待办事项</AssemblyProduct>
<AssemblyCompany>TodoList</AssemblyCompany>
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
@@ -41,6 +48,32 @@
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<TodoListWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../TodoList.Web'))</TodoListWebDir>
<TodoListWebDistDir>$(TodoListWebDir)\dist</TodoListWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidUseAapt2>True</AndroidUseAapt2>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<ItemGroup>
@@ -52,6 +85,7 @@
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Include="icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
@@ -62,24 +96,52 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TodoList.Application\TodoList.Application.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="icon.ico" />
</ItemGroup>
<ItemGroup>
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" LinkBase="wwwroot" />
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.1742" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
<PackageReference Include="System.Windows.Forms" Version="1.0.0" />
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>
@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=wwwroot/@EntryIndexedValue">False</s:Boolean></wpf:ResourceDictionary>
@@ -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<string> Priorities { get; } = new ObservableCollection<string>
{
"高",
"中",
"低"
};
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();
}
}
+3 -27
View File
@@ -1,32 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="TodoList.Maui.Views.MainPage"
BackgroundColor="#F5F5F7">
x:Class="TodoList.Maui.Views.MainPage">
<Grid RowDefinitions="Auto,*">
<Button x:Name="HotKeySettingsButton"
Text="⌨️ 设置快捷键"
Clicked="OnHotKeySettingsClicked"
Margin="10"
Padding="15,8"
BackgroundColor="#0078D4"
TextColor="White"
FontSize="14"
CornerRadius="6"
HorizontalOptions="Start">
<Button.IsVisible>
<OnPlatform x:TypeArguments="x:Boolean">
<On Platform="Windows" Value="True" />
<On Platform="Android" Value="False" />
<On Platform="iOS" Value="False" />
<On Platform="MacCatalyst" Value="False" />
</OnPlatform>
</Button.IsVisible>
</Button>
<WebView x:Name="MainWebView"
Grid.Row="1"
Source="http://localhost:5173" />
</Grid>
<WebView x:Name="MainWebView" />
</ContentPage>
</ContentPage>
+30 -33
View File
@@ -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<AppSettings>() ?? 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<IEmbeddedWebServerService>();
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<HotKeyConfig>(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});");
}
}
}
}
@@ -1,46 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="TodoList.Maui.Views.QuickEntryPage"
Title="新建待办"
BackgroundColor="White">
<Grid Margin="20" RowDefinitions="Auto,Auto,Auto,Auto" ColumnDefinitions="*,*">
<Label Grid.Row="0" Grid.ColumnSpan="2"
Text="新建待办"
FontSize="18"
FontAttributes="Bold"
TextColor="#333333"/>
<Entry Grid.Row="1" Grid.ColumnSpan="2"
x:Name="InputBox"
Placeholder="输入待办事项..."
Text="{Binding Content}"
Margin="0,20,0,10"/>
<Label Grid.Row="2" Grid.Column="0"
Text="优先级:"
VerticalOptions="Center"
TextColor="#666666"
FontSize="14"/>
<Picker Grid.Row="2" Grid.Column="1"
ItemsSource="{Binding Priorities}"
SelectedItem="{Binding Priority}"
Margin="10,0,0,10"/>
<Button Grid.Row="3" Grid.Column="0"
Text="取消"
Command="{Binding CancelCommand}"
BackgroundColor="#F0F0F0"
TextColor="#333333"
Margin="0,10,5,0"/>
<Button Grid.Row="3" Grid.Column="1"
Text="保存"
Command="{Binding SaveCommand}"
BackgroundColor="#007AFF"
TextColor="White"
Margin="5,10,0,0"/>
</Grid>
</ContentPage>
@@ -1,22 +0,0 @@
using TodoList.Maui.Models;
using TodoList.Maui.ViewModels;
namespace TodoList.Maui.Views;
public partial class QuickEntryPage : ContentPage
{
private readonly Action _closeAction;
public QuickEntryPage(Action closeAction)
{
InitializeComponent();
_closeAction = closeAction;
BindingContext = new QuickEntryViewModel(_closeAction);
}
protected override void OnAppearing()
{
base.OnAppearing();
InputBox.Focus();
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"WebServer": {
"Port": 5057,
"IsUsingStatic": false,
"ConnectionString": "",
"HostUrl": "http://localhost:5057",
"ForEndUrl": "http://localhost:5174"
},
"Development": {
},
"HotKey": {
"DefaultModifiers": "Alt",
"DefaultKey": "X",
"DefaultIsEnabled": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+23 -12
View File
@@ -1,13 +1,25 @@
#define MyAppName "TodoList.Maui"
#define MyAppVersion "1.0.0"
#define MyAppName "TodoList"
#define MyAppVersion "1.1.4"
#define MyAppPublisher "ShaoHua"
#define MyAppURL "https://git.we965.cn/Tools/TodoList"
#define MyAppExeName "TodoList.Maui.exe"
#define MyAppExeName "TodoList.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{9B9B7F4G-2345-6789-ABCD-EF1234567890}
; 1. 改用更快压缩(优先速度)
; 压缩方式与级别一起写在 Compression 指令里(例如 lzma/fast、lzma/normal、zip/9 等)
; 注:当前环境下 lzma2 会被 ISCC 报 invalid,因此先用兼容性最好的 lzma/fast
Compression=zip/1
; 或 Compression=none ; 不压缩(仅打包,最快)
; 2. 强制 LZMA2 多线程(多核CPU
; LZMAUseSeparateProcess=yes
; LZMANumBlockThreads=1 ; 仅对 lzma2 生效
; 3. 降低压缩级别(默认 ultra,改 fast/normal
; (压缩级别已在 Compression 中指定)
; 注意: AppId 的值唯一标识此应用程序。不要在其他应用程序的安装程序中使用相同的 AppId 值。
; (若要生成新的 GUID,请在 IDE 中单击“工具”|“生成 GUID”。)
AppId={{9B9B7F4F-2345-6789-ABCD-EF1234567890}}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
@@ -17,13 +29,12 @@ AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
; Remove the following line to run in administrative install mode (install for all users.)
; 删除以下行以在管理安装模式下运行(为所有用户安装)。
PrivilegesRequired=lowest
OutputDir=Output
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
SetupIconFile=Resources\AppIcon\appicon.svg
Compression=lzma
SolidCompression=yes
SetupIconFile=icon.ico
SolidCompression=no
WizardStyle=modern
[Languages]
@@ -33,8 +44,8 @@ Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.i
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "bin\Release\net10.0-windows10.0.19041.0\win10-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
Source: "bin\Release\net10.0-windows10.0.19041.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
; 注意: 请勿在任何共享系统文件上使用“Flags: ignoreversion
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
+102 -1
View File
@@ -1,12 +1,42 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import TaskList from './components/TaskList.vue';
import HotKeySettingsDialog from './components/HotKeySettingsDialog.vue';
const toastMessage = ref('');
const toastType = ref<'error' | 'success'>('error');
const showToast = ref(false);
const showToastNotification = (message: string, type: 'error' | 'success' = 'error') => {
toastMessage.value = message;
toastType.value = type;
showToast.value = true;
setTimeout(() => {
showToast.value = false;
}, 5000);
};
onMounted(() => {
// Expose to window for global access (e.g. from axios interceptor)
(window as any).showToast = showToastNotification;
});
</script>
<template>
<div class="app">
<TaskList />
<HotKeySettingsDialog />
<!-- Global Toast Notification -->
<Transition name="toast">
<div v-if="showToast" class="toast" :class="toastType">
<div class="toast-content">
<span class="toast-icon">{{ toastType === 'error' ? '❌' : '✅' }}</span>
<span class="toast-message">{{ toastMessage }}</span>
</div>
<button @click="showToast = false" class="toast-close">×</button>
</div>
</Transition>
</div>
</template>
@@ -27,7 +57,8 @@ body {
.app {
min-height: 100vh;
background: #f9fafb;
width: 100%;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
h1 {
@@ -37,4 +68,74 @@ h1 {
font-weight: 700;
margin-bottom: 32px;
}
/* Toast Styles */
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 20px;
border-radius: 12px;
background: white;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
gap: 12px;
z-index: 9999;
min-width: 300px;
max-width: 90%;
border-left: 4px solid #ef4444;
}
.toast.success {
border-left-color: #22c55e;
}
.toast.error {
border-left-color: #ef4444;
}
.toast-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.toast-message {
font-size: 14px;
color: #1f2937;
font-weight: 500;
white-space: pre-line;
}
.toast-close {
background: transparent;
border: none;
font-size: 20px;
color: #9ca3af;
cursor: pointer;
padding: 0 4px;
}
.toast-close:hover {
color: #4b5563;
}
/* Toast Transition */
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast-enter-from {
opacity: 0;
transform: translate(-50%, -20px);
}
.toast-leave-to {
opacity: 0;
transform: translate(-50%, -20px);
}
</style>
+53 -2
View File
@@ -1,17 +1,68 @@
import axios from 'axios';
declare global {
interface Window {
__API_BASE_URL__?: string;
}
}
const apiBaseUrl = window.__API_BASE_URL__ || 'http://localhost:5173/api';
const apiClient = axios.create({
baseURL: 'http://localhost:5057/api',
baseURL: apiBaseUrl,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
if ((window as any).showToast) {
(window as any).showToast(message, type);
} else {
window.alert(message);
}
};
apiClient.interceptors.response.use(
(response) => response,
(response) => {
const data = response.data;
console.log('API Response:', response.config.url, data);
// Handle business errors even if status is 200
if (data && typeof data === 'object' && (data.success === false || data.Success === false)) {
const errors = data.errors || data.Errors;
const message = data.message || data.Message;
const errorMessage = errors && Array.isArray(errors) && errors.length > 0
? errors.join('\n')
: message || '操作失败';
console.error('Business Error:', errorMessage);
showNotification(errorMessage, 'error');
return Promise.reject(new Error(errorMessage));
}
return response;
},
(error) => {
console.error('API Error:', error);
let errorMessage = '网络错误,请稍后再试';
if (error.response && error.response.data) {
const data = error.response.data;
const errors = data.errors || data.Errors;
const message = data.message || data.Message;
if (errors && Array.isArray(errors) && errors.length > 0) {
errorMessage = errors.join('\n');
} else if (message) {
errorMessage = message;
}
} else if (error.message) {
errorMessage = error.message;
}
showNotification(errorMessage, 'error');
return Promise.reject(error);
}
);
+119 -18
View File
@@ -1,6 +1,7 @@
import apiClient from './client';
import type { Task, CreateTaskDto, UpdateTaskDto, ApiResponse } from '../types/task';
import LocalStorageService from '../services/localStorageService';
import { normalizeTask, normalizeTasks } from '../services/taskNormalizer';
export const taskApi = {
async getTasks(completed?: boolean): Promise<ApiResponse<Task[]>> {
@@ -15,12 +16,29 @@ export const taskApi = {
};
}
const response = await apiClient.get<ApiResponse<Task[]>>('/tasks', {
params: completed !== undefined ? { completed } : {},
});
const endpoint = completed === true
? '/task/completed'
: completed === false
? '/task/active'
: '/task';
const response = await apiClient.get<Task[] | ApiResponse<Task[]>>(endpoint);
let apiResponse: ApiResponse<Task[]>;
if (Array.isArray(response.data)) {
// 后端直接返回任务数组
apiResponse = {
success: true,
data: response.data,
message: '获取任务成功'
};
} else {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<Task[]>;
}
const apiResponse = response.data;
if (apiResponse.success && apiResponse.data) {
apiResponse.data = normalizeTasks(apiResponse.data);
LocalStorageService.saveTasks(apiResponse.data);
const syncStatus = LocalStorageService.loadSyncStatus();
syncStatus.lastSyncTime = Date.now();
@@ -43,8 +61,26 @@ export const taskApi = {
};
}
const response = await apiClient.get<ApiResponse<Task>>(`/tasks/${id}`);
return response.data;
const response = await apiClient.get<Task | ApiResponse<Task>>(`/task/${id}`);
let apiResponse: ApiResponse<Task>;
if (response.data && 'id' in response.data) {
// 后端直接返回任务对象
apiResponse = {
success: true,
data: response.data,
message: '获取任务成功'
};
} else {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<Task>;
}
if (apiResponse.success && apiResponse.data) {
apiResponse.data = normalizeTask(apiResponse.data);
}
return apiResponse;
},
async createTask(dto: CreateTaskDto): Promise<ApiResponse<Task>> {
@@ -75,10 +111,23 @@ export const taskApi = {
};
}
const response = await apiClient.post<ApiResponse<Task>>('/tasks', dto);
const response = await apiClient.post<Task | ApiResponse<Task>>('/task', dto);
let apiResponse: ApiResponse<Task>;
if (response.data && 'id' in response.data) {
// 后端直接返回任务对象
apiResponse = {
success: true,
data: response.data,
message: '创建任务成功'
};
} else {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<Task>;
}
const apiResponse = response.data;
if (apiResponse.success && apiResponse.data) {
apiResponse.data = normalizeTask(apiResponse.data);
const localTasks = LocalStorageService.loadTasks();
const existingIndex = localTasks.findIndex(t => t.id === newTask.id);
if (existingIndex !== -1) {
@@ -93,6 +142,8 @@ export const taskApi = {
},
async updateTask(id: number, dto: UpdateTaskDto): Promise<ApiResponse<Task>> {
const updateDto = dto;
if (!LocalStorageService.isOnline()) {
const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id);
@@ -101,7 +152,7 @@ export const taskApi = {
if (dto.title) {
localTasks[taskIndex].title = dto.title;
}
if (dto.priority) {
if (dto.priority !== undefined) {
localTasks[taskIndex].priority = dto.priority;
}
localTasks[taskIndex].updatedAt = new Date().toISOString();
@@ -124,10 +175,23 @@ export const taskApi = {
};
}
const response = await apiClient.put<ApiResponse<Task>>(`/tasks/${id}`, dto);
const response = await apiClient.put<Task | ApiResponse<Task>>('/task', updateDto);
let apiResponse: ApiResponse<Task>;
if (response.data && 'id' in response.data) {
// 后端直接返回任务对象
apiResponse = {
success: true,
data: response.data,
message: '更新任务成功'
};
} else {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<Task>;
}
const apiResponse = response.data;
if (apiResponse.success && apiResponse.data) {
apiResponse.data = normalizeTask(apiResponse.data);
const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id);
if (taskIndex !== -1) {
@@ -166,10 +230,23 @@ export const taskApi = {
};
}
const response = await apiClient.patch<ApiResponse<Task>>(`/tasks/${id}/complete`);
const response = await apiClient.patch<Task | ApiResponse<Task>>(`/task/${id}/toggle`);
let apiResponse: ApiResponse<Task>;
if (response.data && 'id' in response.data) {
// 后端直接返回任务对象
apiResponse = {
success: true,
data: response.data,
message: '切换任务状态成功'
};
} else {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<Task>;
}
const apiResponse = response.data;
if (apiResponse.success && apiResponse.data) {
apiResponse.data = normalizeTask(apiResponse.data);
const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id);
if (taskIndex !== -1) {
@@ -206,9 +283,20 @@ export const taskApi = {
};
}
const response = await apiClient.delete<ApiResponse<object>>(`/tasks/${id}`);
const response = await apiClient.delete<any | ApiResponse<object>>(`/task/${id}`);
let apiResponse: ApiResponse<object>;
if (response.data && typeof response.data === 'object' && 'success' in response.data) {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<object>;
} else {
// 后端返回其他格式
apiResponse = {
success: true,
message: '删除任务成功'
};
}
const apiResponse = response.data;
if (apiResponse.success) {
const localTasks = LocalStorageService.loadTasks();
const taskIndex = localTasks.findIndex(t => t.id === id);
@@ -241,10 +329,23 @@ export const taskApi = {
}
try {
const response = await apiClient.get<ApiResponse<Task[]>>('/tasks');
const response = await apiClient.get<Task[] | ApiResponse<Task[]>>('/task');
let apiResponse: ApiResponse<Task[]>;
if (Array.isArray(response.data)) {
// 后端直接返回任务数组
apiResponse = {
success: true,
data: response.data,
message: '获取任务成功'
};
} else {
// 后端返回包装的响应对象
apiResponse = response.data as ApiResponse<Task[]>;
}
const apiResponse = response.data;
if (apiResponse.success && apiResponse.data) {
apiResponse.data = normalizeTasks(apiResponse.data);
LocalStorageService.saveTasks(apiResponse.data);
syncStatus.lastSyncTime = Date.now();
syncStatus.isOnline = true;
@@ -270,4 +371,4 @@ export const taskApi = {
};
}
},
};
};
@@ -114,8 +114,8 @@ function saveConfig() {
isEnabled: localConfig.isEnabled
};
if (window.chrome?.webview?.postMessage) {
window.chrome.webview.postMessage(`HOTKEY_CONFIG:${JSON.stringify(config)}`);
if ((window as any).chrome?.webview?.postMessage) {
(window as any).chrome.webview.postMessage(`HOTKEY_CONFIG:${JSON.stringify(config)}`);
}
close();
@@ -20,9 +20,9 @@
<label class="form-label">优先级</label>
<select v-model="taskPriority" class="form-select">
<option value="" disabled>请选择优先级</option>
<option value="Low"></option>
<option value="Medium"></option>
<option value="High"></option>
<option :value="0"></option>
<option :value="1"></option>
<option :value="2"></option>
</select>
</div>
</div>
@@ -52,7 +52,7 @@ const emit = defineEmits<{
}>();
const taskTitle = ref('');
const taskPriority = ref<'Low' | 'Medium' | 'High'>('Medium');
const taskPriority = ref<0 | 1 | 2>(1);
watch(() => props.task, (newTask) => {
if (newTask) {
@@ -69,6 +69,7 @@ const handleSave = async () => {
try {
const response = await taskApi.updateTask(props.task.id, {
id: props.task.id,
title: taskTitle.value.trim(),
priority: taskPriority.value,
});
@@ -78,8 +79,8 @@ const handleSave = async () => {
emit('update:visible', false);
}
} catch (error) {
console.error('Failed to update task:', error);
alert('更新任务失败');
console.error('Failed to update task:', error);
alert('更新任务失败');
}
};
@@ -99,17 +100,19 @@ const handleCancel = () => {
display: flex;
align-items: center;
justify-content: center;
padding: 20px 16px;
z-index: 1000;
backdrop-filter: blur(4px);
}
.dialog-content {
background: white;
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
width: 90%;
max-width: 500px;
animation: dialogSlideIn 0.3s ease;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: min(100%, 420px);
border: 1px solid rgba(99, 102, 241, 0.12);
overflow: hidden;
animation: dialogSlideIn 0.22s ease;
}
@keyframes dialogSlideIn {
@@ -127,26 +130,27 @@ const handleCancel = () => {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
padding: 12px 14px;
border-bottom: 1px solid #e5e7eb;
}
.dialog-title {
margin: 0;
font-size: 20px;
font-size: 16px;
font-weight: 700;
color: #1e293b;
line-height: 1.3;
}
.btn-close {
width: 32px;
height: 32px;
width: 28px;
height: 28px;
border: none;
background: #f1f5f9;
background: transparent;
color: #64748b;
border-radius: 8px;
cursor: pointer;
font-size: 24px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
@@ -155,139 +159,140 @@ const handleCancel = () => {
}
.btn-close:hover {
background: #e2e8f0;
background: #f1f5f9;
color: #334155;
}
.dialog-body {
padding: 24px;
display: grid;
gap: 14px;
padding: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-label {
display: block;
margin-bottom: 8px;
font-size: 14px;
margin-bottom: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
color: #334155;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 15px;
transition: all 0.3s ease;
background: #f8fafc;
min-height: 40px;
padding: 8px;
border: none;
border-bottom: 2px solid #e2e8f0;
border-radius: 0;
font-size: 14px;
transition: all 0.2s ease;
background: transparent;
color: #1e293b;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #6366f1;
background: white;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
border-bottom-color: #6366f1;
background: transparent;
box-shadow: none;
}
.form-select {
width: 100%;
padding: 12px 40px 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 15px;
min-height: 40px;
padding: 10px 40px 10px 14px;
border: 2px solid #e2e8f0;
border-radius: 14px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
background: #f8fafc;
color: #64748b;
color: #1e293b;
box-sizing: border-box;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2364748b'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 20px;
background-position: right 10px center;
background-size: 18px;
}
.form-select:hover {
border-color: #94a3b8;
background-color: #f1f5f9;
border-color: #cbd5e1;
background-color: #f8fafc;
}
.form-select:focus {
outline: none;
border-color: #6366f1;
background: white;
color: #1e293b;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
}
.form-select option {
padding: 10px;
background: white;
color: #1e293b;
}
.form-select option[value="Low"] {
.form-select option[value="0"] {
color: #22c55e;
}
.form-select option[value="Medium"] {
.form-select option[value="1"] {
color: #f59e0b;
}
.form-select option[value="High"] {
.form-select option[value="2"] {
color: #ef4444;
}
.dialog-footer {
display: flex;
gap: 12px;
gap: 8px;
justify-content: flex-end;
padding: 20px 24px;
padding: 12px 16px 16px;
border-top: 1px solid #e5e7eb;
background: #f8fafc;
}
.btn-cancel {
padding: 10px 24px;
background: #f1f5f9;
color: #475569;
min-width: 76px;
padding: 8px 18px;
background: #e5e7eb;
color: #374151;
border: none;
border-radius: 10px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
transition: background 0.2s;
}
.btn-cancel:hover {
background: #e2e8f0;
color: #334155;
background: #d1d5db;
}
.btn-save {
padding: 10px 24px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
min-width: 76px;
padding: 8px 18px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
border-radius: 10px;
border-radius: 14px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 12px -2px rgba(99, 102, 241, 0.4);
box-shadow: 0 4px 12px -2px rgba(37, 99, 235, 0.4);
}
.btn-save:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px -2px rgba(99, 102, 241, 0.5);
box-shadow: 0 8px 16px -2px rgba(37, 99, 235, 0.5);
}
.btn-save:active {
@@ -296,30 +301,30 @@ const handleCancel = () => {
@media (max-width: 768px) {
.dialog-content {
width: 95%;
max-width: none;
border-radius: 14px;
width: 100%;
border-radius: 20px;
}
.dialog-header {
padding: 16px 20px;
padding: 12px 14px;
}
.dialog-title {
font-size: 18px;
font-size: 16px;
}
.dialog-body {
padding: 20px;
gap: 12px;
padding: 14px;
}
.dialog-footer {
padding: 16px 20px;
padding: 10px 14px 14px;
}
.btn-cancel,
.btn-save {
padding: 10px 20px;
padding: 8px 16px;
font-size: 14px;
}
}
+404 -82
View File
@@ -1,25 +1,35 @@
<template>
<div class="task-item" :class="{ completed: task.isCompleted, 'priority-low': task.priority === 'Low', 'priority-medium': task.priority === 'Medium', 'priority-high': task.priority === 'High' }">
<div class="task-item" :class="{ completed: task.isCompleted, 'priority-low': task.priority === 0, 'priority-medium': task.priority === 1, 'priority-high': task.priority === 2 }">
<div class="task-content" @dblclick="openEditDialog">
<div class="task-expand" @click="toggleExpand" v-if="task.subTasks && task.subTasks.length > 0">
<span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span>
</div>
<div v-else class="task-expand-spacer"></div>
<input
type="checkbox"
:checked="task.isCompleted"
@change="toggleComplete"
class="task-checkbox"
/>
<button @click="showAddSubTask = true" class="btn-add-subtask-icon" title="添加子任务">
<button v-if="task.subTasks && task.subTasks.length > 0" class="task-expand" @click.stop="toggleExpand" :title="isExpanded ? '收起' : '展开'">
<span class="expand-icon">{{ isExpanded ? '▼' : '▶' }}</span>
</button>
<button @click="startAddSubTask" class="btn-add-subtask-icon" title="添加子任务">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
</button>
<div class="task-info">
<span class="task-title">{{ task.title }}</span>
<div class="task-meta">
<span class="task-id task-id--compact">#{{ task.id }}</span>
<span class="task-priority task-priority--compact">{{ priorityShortText }}</span>
<span class="task-created">创建{{ createdAtText }}</span>
</div>
</div>
<div class="task-actions">
<button @click="openEditDialog" class="btn-edit-icon" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 20h9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5Z" />
</svg>
</button>
<button @click="deleteTask" class="btn-delete-icon" title="删除">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -30,6 +40,7 @@
<div v-if="showAddSubTask" class="add-subtask-form">
<input
ref="subTaskInput"
v-model="newSubTaskTitle"
type="text"
placeholder="输入子任务标题..."
@@ -37,12 +48,21 @@
class="subtask-input"
/>
<select v-model="newSubTaskPriority" class="subtask-select">
<option value="Low"></option>
<option value="Medium"></option>
<option value="High"></option>
<option :value="0"></option>
<option :value="1"></option>
<option :value="2"></option>
</select>
<button @click="createSubTask" class="btn-create-subtask">创建</button>
<button @click="showAddSubTask = false" class="btn-cancel">取消</button>
<button @click="createSubTask" class="btn-create-subtask" title="创建子任务">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button @click="showAddSubTask = false" class="btn-cancel" title="取消">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"></path>
</svg>
</button>
</div>
<div v-if="isExpanded && task.subTasks && task.subTasks.length > 0" class="subtasks">
@@ -65,7 +85,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { computed, ref, nextTick } from 'vue';
import { taskApi } from '../api/tasks';
import TaskEditDialog from './TaskEditDialog.vue';
import type { Task } from '../types/task';
@@ -86,7 +106,46 @@ const isExpanded = ref(false);
const showAddSubTask = ref(false);
const showEditDialog = ref(false);
const newSubTaskTitle = ref('');
const newSubTaskPriority = ref<'Low' | 'Medium' | 'High'>('Medium');
const newSubTaskPriority = ref<0 | 1 | 2>(1);
const subTaskInput = ref<HTMLInputElement | null>(null);
const startAddSubTask = async () => {
showAddSubTask.value = true;
await nextTick();
if (subTaskInput.value) {
subTaskInput.value.focus();
}
};
const priorityText = computed(() => {
switch (props.task.priority) {
case 0:
return '低';
case 2:
return '高';
case 1:
default:
return '中';
}
});
const priorityShortText = computed(() => priorityText.value);
const formatDateTime = (value: string): string => {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return '';
const pad2 = (n: number) => String(n).padStart(2, '0');
const y = date.getFullYear();
const m = pad2(date.getMonth() + 1);
const d = pad2(date.getDate());
const hh = pad2(date.getHours());
const mm = pad2(date.getMinutes());
const ss = pad2(date.getSeconds());
return `${y}-${m}-${d} ${hh}:${mm}:${ss}`;
};
const createdAtText = computed(() => formatDateTime(props.task.createdAt));
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
@@ -100,7 +159,6 @@ const toggleComplete = async () => {
}
} catch (error) {
console.error('Failed to toggle task:', error);
alert('切换任务状态失败');
}
};
@@ -112,7 +170,6 @@ const deleteTask = async () => {
}
} catch (error) {
console.error('Failed to delete task:', error);
alert('删除任务失败');
}
};
@@ -130,14 +187,13 @@ const createSubTask = async () => {
});
if (response.success && response.data) {
emit('subtask-created', response.data);
showAddSubTask.value = false;
newSubTaskTitle.value = '';
newSubTaskPriority.value = 'Medium';
}
emit('subtask-created', response.data);
showAddSubTask.value = false;
newSubTaskTitle.value = '';
newSubTaskPriority.value = 1;
}
} catch (error) {
console.error('Failed to create subtask:', error);
alert('创建子任务失败');
}
};
@@ -171,46 +227,50 @@ const handleTaskSaved = (updatedTask: Task) => {
.task-item {
display: flex;
flex-direction: column;
padding: 8px 12px;
padding: 10px 12px;
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-bottom: 6px;
transition: all 0.2s;
border: 1px solid rgba(226, 232, 240, 0.9);
border-left-width: 8px;
border-radius: 12px;
margin-bottom: 0;
transition: background 0.2s, box-shadow 0.2s, transform 0.2s, border-color 0.2s;
user-select: none;
-webkit-tap-highlight-color: transparent;
text-align: left;
}
.task-item.priority-low {
background: rgba(209, 250, 229, 0.5);
border-color: rgba(167, 243, 208, 0.6);
background: rgba(209, 250, 229, 0.65);
border-color: rgba(167, 243, 208, 0.75);
border-left-color: rgba(34, 197, 94, 0.9);
}
.task-item.priority-medium {
background: rgba(254, 243, 199, 0.5);
border-color: rgba(253, 230, 138, 0.6);
background: rgba(254, 243, 199, 0.7);
border-color: rgba(253, 230, 138, 0.8);
border-left-color: rgba(245, 158, 11, 0.9);
}
.task-item.priority-high {
background: rgba(254, 226, 226, 0.5);
border-color: rgba(254, 202, 202, 0.6);
background: rgba(254, 226, 226, 0.7);
border-color: rgba(254, 202, 202, 0.85);
border-left-color: rgba(239, 68, 68, 0.9);
}
.task-item.priority-low:hover {
background: rgba(209, 250, 229, 0.7);
border-color: rgba(167, 243, 208, 0.8);
background: rgba(209, 250, 229, 0.85);
}
.task-item.priority-medium:hover {
background: rgba(254, 243, 199, 0.7);
border-color: rgba(253, 230, 138, 0.8);
background: rgba(254, 243, 199, 0.85);
}
.task-item.priority-high:hover {
background: rgba(254, 226, 226, 0.7);
border-color: rgba(254, 202, 202, 0.8);
background: rgba(254, 226, 226, 0.85);
}
.task-item:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
box-shadow: 0 10px 20px -12px rgba(15, 23, 42, 0.25);
}
.task-item.completed {
@@ -224,58 +284,164 @@ const handleTaskSaved = (updatedTask: Task) => {
.task-content {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
flex: 1;
cursor: pointer;
user-select: none;
}
.task-content:hover {
background: rgba(249, 250, 251, 0.5);
border-radius: 4px;
background: transparent;
}
.task-expand {
cursor: pointer;
width: 16px;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.expand-icon {
font-size: 10px;
font-size: 12px;
color: #6b7280;
transition: transform 0.2s;
}
.task-expand-spacer {
width: 16px;
.task-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
border: 1.5px solid rgba(100, 116, 139, 0.6);
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
display: grid;
place-content: center;
transition: background-color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.task-checkbox {
width: 16px;
height: 16px;
cursor: pointer;
.task-checkbox::after {
content: '';
width: 6px;
height: 10px;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
transform: translateY(-1px) rotate(45deg);
opacity: 0;
transition: opacity 0.2s ease;
}
.task-checkbox:checked {
background: #6366f1;
border-color: #6366f1;
}
.task-checkbox:checked::after {
opacity: 1;
}
.task-checkbox:focus-visible {
outline: 2px solid rgba(99, 102, 241, 0.55);
outline-offset: 2px;
}
@media (prefers-color-scheme: dark) {
.task-checkbox {
border-color: rgba(148, 163, 184, 0.75);
background: rgba(15, 23, 42, 0.35);
}
}
.task-info {
display: flex;
align-items: center;
gap: 8px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
flex: 1;
min-width: 0;
}
.task-title {
font-size: 13px;
font-size: 15px;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.task-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.task-created {
font-size: 12px;
line-height: 1.2;
color: rgba(71, 85, 105, 0.85);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.task-id {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
padding: 2px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.14);
color: rgba(71, 85, 105, 0.9);
font-variant-numeric: tabular-nums;
}
.task-id--compact {
font-size: 11px;
padding: 1px 6px;
}
.task-priority {
flex-shrink: 0;
font-size: 12px;
font-weight: 600;
line-height: 1.2;
padding: 2px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.9);
}
.task-priority--compact {
font-size: 11px;
padding: 1px 6px;
}
.task-item.priority-low .task-priority {
background: rgba(34, 197, 94, 0.14);
color: rgba(21, 128, 61, 0.95);
}
.task-item.priority-medium .task-priority {
background: rgba(245, 158, 11, 0.16);
color: rgba(180, 83, 9, 0.95);
}
.task-item.priority-high .task-priority {
background: rgba(239, 68, 68, 0.14);
color: rgba(185, 28, 28, 0.95);
}
.task-actions {
display: flex;
gap: 6px;
@@ -283,6 +449,31 @@ const handleTaskSaved = (updatedTask: Task) => {
align-items: center;
}
.btn-edit-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
color: #2563eb;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-edit-icon:hover {
background: #dbeafe;
color: #1d4ed8;
}
.btn-edit-icon svg {
flex-shrink: 0;
}
.btn-add-subtask {
padding: 4px 10px;
background: #dbeafe;
@@ -357,61 +548,192 @@ const handleTaskSaved = (updatedTask: Task) => {
.add-subtask-form {
display: flex;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e5e7eb;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
margin-top: 10px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.55);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.subtask-input {
flex: 1;
padding: 6px;
border: 1px solid #d1d5db;
border-radius: 5px;
font-size: 11px;
width: 100%;
padding: 8px;
border: none;
border-bottom: 1.5px solid rgba(0, 0, 0, 0.08);
border-radius: 0;
font-size: 14px;
transition: all 0.3s ease;
background: transparent;
color: #1e293b;
}
.subtask-input::placeholder {
color: #94a3b8;
}
.subtask-input:focus {
outline: none;
border-bottom-color: #6366f1;
background: transparent;
box-shadow: none;
}
.subtask-select {
padding: 6px;
border: 1px solid #d1d5db;
border-radius: 5px;
font-size: 11px;
padding: 4px 8px;
border: 1.5px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.6);
min-width: 60px;
color: #1e293b;
}
.subtask-select:focus {
outline: none;
border-color: #6366f1;
background: white;
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
}
.subtask-select option {
background: white;
color: #1e293b;
}
.subtask-select option[value="0"] {
color: #22c55e;
}
.subtask-select option[value="1"] {
color: #f59e0b;
}
.subtask-select option[value="2"] {
color: #ef4444;
}
.btn-create-subtask {
padding: 6px 12px;
background: #3b82f6;
padding: 4px 12px;
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
border-radius: 5px;
border-radius: 8px;
cursor: pointer;
font-size: 11px;
transition: background 0.2s;
font-size: 14px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 12px -2px rgba(37, 99, 235, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.btn-create-subtask:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 8px 16px -2px rgba(37, 99, 235, 0.4);
}
.btn-create-subtask:active {
transform: translateY(0);
}
.btn-cancel {
padding: 6px 12px;
background: #e5e7eb;
color: #374151;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
color: #dc2626;
border: none;
border-radius: 5px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
transition: background 0.2s;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-cancel:hover {
background: #d1d5db;
background: #fee2e2;
color: #991b1b;
}
.btn-cancel:hover {
background: #e2e8f0;
color: #475569;
}
.add-subtask-form > * {
box-sizing: border-box;
}
@media (max-width: 600px) {
.add-subtask-form {
flex-wrap: wrap;
border-radius: 16px;
}
.subtask-input {
flex: 1 1 100%;
margin-bottom: 4px;
}
}
.subtasks {
margin-top: 8px;
padding-left: 24px;
border-left: 2px solid #e5e7eb;
margin-top: 10px;
margin-left: 14px;
padding: 0;
border: none;
background: transparent;
border-radius: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.subtasks :deep(.task-item) {
border-left-width: 5px;
padding: 10px 12px;
box-shadow: none;
}
.subtasks :deep(.task-item:hover) {
box-shadow: none;
}
.subtasks :deep(.task-item) {
border-top-width: 0;
border-right-width: 0;
border-bottom-width: 0;
}
.subtasks :deep(.task-item.priority-low),
.subtasks :deep(.task-item.priority-medium),
.subtasks :deep(.task-item.priority-high) {
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
}
.task-item ::selection {
background: transparent;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import type { Task } from '../types/task';
import { normalizeTasks } from './taskNormalizer';
const STORAGE_KEY = 'todolist_tasks';
const SYNC_STATUS_KEY = 'todolist_sync_status';
@@ -12,7 +13,7 @@ export interface SyncStatus {
export class LocalStorageService {
static saveTasks(tasks: Task[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeTasks(tasks)));
} catch (error) {
console.error('Failed to save tasks to localStorage:', error);
}
@@ -21,7 +22,7 @@ export class LocalStorageService {
static loadTasks(): Task[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
return data ? normalizeTasks(JSON.parse(data)) : [];
} catch (error) {
console.error('Failed to load tasks from localStorage:', error);
return [];
@@ -0,0 +1,68 @@
import type { Task, TaskPriority } from '../types/task';
const toTaskPriority = (value: unknown): TaskPriority => {
if (value === 0 || value === 1 || value === 2) {
return value;
}
if (typeof value === 'number' && Number.isFinite(value)) {
if (value <= 0) return 0;
if (value === 1) return 1;
return 2;
}
if (typeof value === 'string') {
const raw = value.trim();
if (raw === '0' || raw === '1' || raw === '2') {
return Number(raw) as TaskPriority;
}
const lower = raw.toLowerCase();
if (lower === 'low') return 0;
if (lower === 'medium' || lower === 'mid') return 1;
if (lower === 'high') return 2;
}
return 1;
};
const toIsoString = (value: unknown): string => {
if (typeof value === 'string' && value.trim()) return value;
if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString();
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
return new Date().toISOString();
};
export const normalizeTask = (task: any): Task => {
const normalized: any = { ...(task ?? {}) };
if (typeof normalized.id === 'string' && /^\d+$/.test(normalized.id)) {
normalized.id = Number(normalized.id);
}
normalized.title = typeof normalized.title === 'string' ? normalized.title : String(normalized.title ?? '');
normalized.priority = toTaskPriority(normalized.priority);
normalized.isCompleted = typeof normalized.isCompleted === 'boolean' ? normalized.isCompleted : Boolean(normalized.isCompleted);
normalized.createdAt = toIsoString(normalized.createdAt);
normalized.updatedAt = toIsoString(normalized.updatedAt);
if (normalized.parentTaskId === null) {
normalized.parentTaskId = undefined;
} else if (typeof normalized.parentTaskId === 'string' && /^\d+$/.test(normalized.parentTaskId)) {
normalized.parentTaskId = Number(normalized.parentTaskId);
}
if (Array.isArray(normalized.subTasks)) {
normalized.subTasks = normalized.subTasks.map(normalizeTask);
} else {
normalized.subTasks = [];
}
return normalized as Task;
};
export const normalizeTasks = (tasks: unknown): Task[] => {
if (!Array.isArray(tasks)) return [];
return tasks.map(normalizeTask);
};
+3 -5
View File
@@ -157,11 +157,9 @@ code {
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
width: 100%;
max-width: none;
margin: 0;
min-height: 100svh;
display: flex;
flex-direction: column;
+2 -1
View File
@@ -1,4 +1,4 @@
export type TaskPriority = 'Low' | 'Medium' | 'High';
export type TaskPriority = 0 | 1 | 2;
export interface Task {
id: number;
@@ -18,6 +18,7 @@ export interface CreateTaskDto {
}
export interface UpdateTaskDto {
id: number;
title?: string;
priority?: TaskPriority;
}
+18 -1
View File
@@ -1,7 +1,24 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
outDir: resolve(__dirname, 'dist'),
emptyOutDir: true,
},
define: {
'window.__API_BASE_URL__': JSON.stringify('/api')
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:5057',
changeOrigin: true,
secure: false,
},
},
},
})
+81
View File
@@ -0,0 +1,81 @@
param(
[switch]$Force = $false
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " TodoList.Web 启动脚本" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$webProjectPath = Join-Path $PSScriptRoot "src\TodoList.Web"
Write-Host "[1/2] 检查 TodoList.Web 服务..." -ForegroundColor Yellow
$webProcess = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like "*vite*" -or $_.CommandLine -like "*TodoList.Web*" }
if ($webProcess) {
Write-Host "⚠️ TodoList.Web 服务已经在运行中" -ForegroundColor Yellow
Write-Host " 进程 ID: ($($webProcess.ProcessId))" -ForegroundColor Gray
if ($Force) {
Write-Host ""
Write-Host "🔄 强制重启..." -ForegroundColor Yellow
try {
Stop-Process -Id $webProcess.ProcessId -Force -ErrorAction Stop
Write-Host "✅ 旧进程已停止" -ForegroundColor Green
Start-Sleep -Seconds 1
} catch {
Write-Host "❌ 停止进程失败: $_" -ForegroundColor Red
exit 1
}
} else {
Write-Host " 如果需要重新启动,请使用 -Force 参数" -ForegroundColor Gray
Write-Host ""
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " 已在运行" -ForegroundColor Green
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "💡 访问地址:" -ForegroundColor Yellow
Write-Host " http://localhost:5174" -ForegroundColor Cyan
Write-Host ""
exit 0
}
} else {
Write-Host "✓ TodoList.Web 服务未运行" -ForegroundColor Green
Write-Host ""
}
Write-Host "[2/2] 启动 TodoList.Web 服务..." -ForegroundColor Yellow
if (!(Test-Path $webProjectPath)) {
Write-Host "❌ Web 项目路径不存在: $webProjectPath" -ForegroundColor Red
exit 1
}
Write-Host "📂 工作目录: $webProjectPath" -ForegroundColor Gray
Write-Host "🚀 启动服务..." -ForegroundColor Green
try {
$webProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -WorkingDirectory $webProjectPath -PassThru
Write-Host "✅ TodoList.Web 服务已启动" -ForegroundColor Green
Write-Host " 进程 ID: ($($webProcess.Id))" -ForegroundColor Gray
Write-Host ""
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " 启动完成" -ForegroundColor Green
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "💡 访问地址:" -ForegroundColor Yellow
Write-Host " http://localhost:5174" -ForegroundColor Cyan
Write-Host ""
Write-Host "📝 提示:" -ForegroundColor Yellow
Write-Host " - 按 Ctrl+C 可以停止服务" -ForegroundColor Gray
Write-Host " - 运行 stop-web.ps1 可以关闭服务" -ForegroundColor Gray
Write-Host " - 使用 -Force 参数可以强制重启" -ForegroundColor Gray
Write-Host ""
} catch {
Write-Host "❌ 启动服务失败: $_" -ForegroundColor Red
exit 1
}
+96
View File
@@ -0,0 +1,96 @@
param(
[switch]$Force = $false,
[string]$Framework = "net10.0-windows10.0.19041.0",
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",
[switch]$SkipWebBuild = $false
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " TodoList.Maui 启动脚本" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$mauiProjectPath = Join-Path $PSScriptRoot "src\TodoList.Maui"
$mauiProjectFile = Join-Path $mauiProjectPath "TodoList.Maui.csproj"
if (!(Test-Path $mauiProjectFile)) {
Write-Host "❌ MAUI 项目文件不存在: $mauiProjectFile" -ForegroundColor Red
exit 1
}
if (!(Get-Command "dotnet" -ErrorAction SilentlyContinue)) {
Write-Host "❌ 未找到 dotnet,请先安装 .NET SDK" -ForegroundColor Red
exit 1
}
Write-Host "[1/2] 检查 TodoList.Maui 是否已在运行..." -ForegroundColor Yellow
$runningProcesses = Get-CimInstance Win32_Process -Filter "Name='TodoList.Maui.exe' OR Name='dotnet.exe'" | Where-Object {
$_.CommandLine -like "*TodoList.Maui*"
}
if ($runningProcesses) {
$pids = ($runningProcesses.ProcessId | Sort-Object) -join ", "
Write-Host "⚠️ TodoList.Maui 可能已在运行中" -ForegroundColor Yellow
Write-Host " 进程 ID: ($pids)" -ForegroundColor Gray
if ($Force) {
Write-Host ""
Write-Host "🔄 强制关闭并重新启动..." -ForegroundColor Yellow
try {
$runningProcesses | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop }
Write-Host "✅ 旧进程已停止" -ForegroundColor Green
Start-Sleep -Seconds 1
} catch {
Write-Host "❌ 停止进程失败: $_" -ForegroundColor Red
exit 1
}
} else {
Write-Host " 如果需要重新启动,请使用 -Force 参数" -ForegroundColor Gray
Write-Host ""
exit 0
}
} else {
Write-Host "✓ TodoList.Maui 未运行" -ForegroundColor Green
Write-Host ""
}
Write-Host "[2/2] 启动 TodoList.Maui..." -ForegroundColor Yellow
$argumentList = @(
"build",
$mauiProjectFile,
"-t:Run",
"-c", $Configuration,
"-f", $Framework
)
if ($SkipWebBuild) {
$argumentList += "-p:SkipWebBuild=true"
}
Write-Host "📂 工作目录: $mauiProjectPath" -ForegroundColor Gray
Write-Host "🧩 Framework: $Framework" -ForegroundColor Gray
Write-Host "⚙️ Configuration: $Configuration" -ForegroundColor Gray
Write-Host "🚀 启动应用..." -ForegroundColor Green
Write-Host ""
try {
$mauiProcess = Start-Process -FilePath "dotnet" -ArgumentList $argumentList -WorkingDirectory $mauiProjectPath -PassThru
Write-Host "✅ TodoList.Maui 已启动" -ForegroundColor Green
Write-Host " 进程 ID: ($($mauiProcess.Id))" -ForegroundColor Gray
Write-Host ""
Write-Host "📝 提示:" -ForegroundColor Yellow
Write-Host " - 使用 -Force 参数可以强制重启" -ForegroundColor Gray
Write-Host " - 使用 -SkipWebBuild 可跳过内置 Web 资源构建" -ForegroundColor Gray
Write-Host " - 使用 -Framework 可指定目标框架(如 net10.0-windows10.0.19041.0" -ForegroundColor Gray
Write-Host ""
} catch {
Write-Host "❌ 启动失败: $_" -ForegroundColor Red
exit 1
}
-128
View File
@@ -1,128 +0,0 @@
param(
[string]$ServicePath = "src\TodoList.Api",
[string]$WebPath = "src\TodoList.Web",
[string]$MauiPath = "src\TodoList.Maui",
[switch]$StartMaui = $true
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " TodoList 服务启动脚本" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$apiProjectPath = Join-Path $PSScriptRoot $ServicePath
$webProjectPath = Join-Path $PSScriptRoot $WebPath
$mauiProjectPath = Join-Path $PSScriptRoot $MauiPath
Write-Host "[1/3] 检查 TodoList.Api 服务..." -ForegroundColor Yellow
$apiProcess = Get-Process -Name "dotnet" -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like "*TodoList.Api*" }
if ($apiProcess) {
Write-Host "⚠️ TodoList.Api 服务已经在运行中" -ForegroundColor Yellow
Write-Host " 进程 ID: ($apiProcess.Id)" -ForegroundColor Gray
Write-Host " 如果需要重新启动,请先运行 stop-service.bat" -ForegroundColor Gray
Write-Host ""
if ($StartMaui) {
Write-Host "[2/3] 启动 TodoList.Maui 应用..." -ForegroundColor Yellow
$mauiPath = Join-Path $mauiProjectPath "bin\Debug\net10.0-windows10.0.19041.0\win-x64\TodoList.Maui.exe"
if (Test-Path $mauiPath) {
Write-Host "🚀 启动 TodoList.Maui..." -ForegroundColor Green
Start-Process -FilePath $mauiPath -WorkingDirectory $mauiProjectPath
Write-Host "✅ TodoList.Maui 已启动" -ForegroundColor Green
} else {
Write-Host "❌ TodoList.Maui 可执行文件不存在" -ForegroundColor Red
Write-Host " 路径: $mauiPath" -ForegroundColor Red
Write-Host " 请先构建项目: dotnet build src\TodoList.Maui\TodoList.Maui.csproj" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " 启动完成" -ForegroundColor Green
Write-Host "====================================" -ForegroundColor Cyan
exit 0
}
Write-Host "✓ TodoList.Api 服务未运行" -ForegroundColor Green
Write-Host ""
Write-Host "[2/3] 启动 TodoList.Api 服务..." -ForegroundColor Yellow
if (!(Test-Path $apiProjectPath)) {
Write-Host "❌ API 项目路径不存在: $apiProjectPath" -ForegroundColor Red
exit 1
}
Write-Host "📂 工作目录: $apiProjectPath" -ForegroundColor Gray
Write-Host "🚀 启动服务..." -ForegroundColor Green
$apiProcess = Start-Process -FilePath "dotnet" -ArgumentList "run" -WorkingDirectory $apiProjectPath -PassThru
Write-Host "✅ TodoList.Api 服务已启动" -ForegroundColor Green
Write-Host " 进程 ID: ($apiProcess.Id)" -ForegroundColor Gray
Write-Host " 访问地址: http://localhost:5057" -ForegroundColor Cyan
Write-Host " Swagger 文档: http://localhost:5057/swagger" -ForegroundColor Cyan
Write-Host ""
Write-Host "[3/3] 启动 TodoList.Web 服务..." -ForegroundColor Yellow
$webProcess = Get-Process -Name "node" -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like "*vite*" -or $_.Path -like "*TodoList.Web*" }
if ($webProcess) {
Write-Host "⚠️ TodoList.Web 服务已经在运行中" -ForegroundColor Yellow
Write-Host " 进程 ID: ($webProcess.Id)" -ForegroundColor Gray
Write-Host " 如果需要重新启动,请先运行 stop-service.bat" -ForegroundColor Gray
Write-Host ""
} else {
Write-Host "📂 工作目录: $webProjectPath" -ForegroundColor Gray
if (!(Test-Path $webProjectPath)) {
Write-Host "❌ Web 项目路径不存在: $webProjectPath" -ForegroundColor Red
exit 1
}
Write-Host "🚀 启动 Web 服务..." -ForegroundColor Green
$webProcess = Start-Process -FilePath "npm.cmd" -ArgumentList "run", "dev" -WorkingDirectory $webProjectPath -PassThru
Write-Host "✅ TodoList.Web 服务已启动" -ForegroundColor Green
Write-Host " 进程 ID: ($webProcess.Id)" -ForegroundColor Gray
Write-Host " 访问地址: http://localhost:5173" -ForegroundColor Cyan
Write-Host ""
}
if ($StartMaui) {
Write-Host ""
Write-Host "[4/4] 启动 TodoList.Maui 应用..." -ForegroundColor Yellow
Start-Sleep -Seconds 2
$mauiPath = Join-Path $mauiProjectPath "bin\Debug\net10.0-windows10.0.19041.0\win-x64\TodoList.Maui.exe"
if (Test-Path $mauiPath) {
Write-Host "🚀 启动 TodoList.Maui..." -ForegroundColor Green
Start-Process -FilePath $mauiPath -WorkingDirectory $mauiProjectPath
Write-Host "✅ TodoList.Maui 已启动" -ForegroundColor Green
} else {
Write-Host "❌ TodoList.Maui 可执行文件不存在" -ForegroundColor Red
Write-Host " 路径: $mauiPath" -ForegroundColor Red
Write-Host " 请先构建项目: dotnet build src\TodoList.Maui\TodoList.Maui.csproj" -ForegroundColor Red
}
}
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 ""
-97
View File
@@ -1,97 +0,0 @@
param(
[switch]$Force = $false
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " TodoList 服务关闭脚本" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$stoppedProcesses = @()
Write-Host "[1/3] 查找 TodoList.Api 服务..." -ForegroundColor Yellow
$apiProcesses = Get-Process -Name "dotnet" -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like "*TodoList.Api*" }
if ($apiProcesses) {
Write-Host "🔍 找到 ($apiProcesses.Count) 个 TodoList.Api 进程" -ForegroundColor Yellow
foreach ($process in $apiProcesses) {
Write-Host " 正在停止进程 ID: ($process.Id)..." -ForegroundColor Gray
try {
Stop-Process -Id $process.Id -Force:$Force -ErrorAction Stop
Write-Host " ✅ 进程 ($process.Id) 已停止" -ForegroundColor Green
$stoppedProcesses += $process
} catch {
Write-Host " ❌ 停止进程 ($process.Id) 失败: $_" -ForegroundColor Red
}
}
} else {
Write-Host "✓ TodoList.Api 服务未运行" -ForegroundColor Green
}
Write-Host ""
Write-Host "[2/3] 查找 TodoList.Web 服务..." -ForegroundColor Yellow
$webProcesses = Get-WmiObject Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like "*vite*" -or $_.CommandLine -like "*TodoList.Web*" }
if ($webProcesses) {
$count = @($webProcesses).Count
Write-Host "🔍 找到 ($count) 个 TodoList.Web 进程" -ForegroundColor Yellow
foreach ($process in $webProcesses) {
$processId = $process.ProcessId
Write-Host " 正在停止进程 ID: ($processId)..." -ForegroundColor Gray
try {
Stop-Process -Id $processId -Force:$Force -ErrorAction Stop
Write-Host " ✅ 进程 ($processId) 已停止" -ForegroundColor Green
$stoppedProcesses += $process
} catch {
Write-Host " ❌ 停止进程 ($processId) 失败: $_" -ForegroundColor Red
}
}
} else {
Write-Host "✓ TodoList.Web 服务未运行" -ForegroundColor Green
}
Write-Host ""
Write-Host "[3/3] 查找 TodoList.Maui 应用..." -ForegroundColor Yellow
$mauiProcesses = Get-Process -Name "TodoList.Maui" -ErrorAction SilentlyContinue
if ($mauiProcesses) {
Write-Host "🔍 找到 ($mauiProcesses.Count) 个 TodoList.Maui 进程" -ForegroundColor Yellow
foreach ($process in $mauiProcesses) {
Write-Host " 正在停止进程 ID: ($process.Id)..." -ForegroundColor Gray
try {
Stop-Process -Id $process.Id -Force:$Force -ErrorAction Stop
Write-Host " ✅ 进程 ($process.Id) 已停止" -ForegroundColor Green
$stoppedProcesses += $process
} catch {
Write-Host " ❌ 停止进程 ($process.Id) 失败: $_" -ForegroundColor Red
}
}
} else {
Write-Host "✓ TodoList.Maui 应用未运行" -ForegroundColor Green
}
Write-Host ""
Write-Host "====================================" -ForegroundColor Cyan
$stoppedCount = @($stoppedProcesses).Count
if ($stoppedCount -gt 0) {
Write-Host " 关闭完成" -ForegroundColor Green
Write-Host " 已停止 ($stoppedCount) 个进程" -ForegroundColor Green
} else {
Write-Host " 没有需要停止的进程" -ForegroundColor Yellow
}
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "💡 提示:" -ForegroundColor Yellow
Write-Host " - 运行 start-service.bat 可以启动服务" -ForegroundColor Gray
Write-Host " - 运行 restart-service.bat 可以重启服务" -ForegroundColor Gray
Write-Host ""