Compare commits
3 Commits
81c649cb23
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a91e39b6 | |||
| 4daa0c4eba | |||
| ceb77e624e |
@@ -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
|
||||
|
||||
Vendored
+35
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+41
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -64,7 +64,7 @@ dotnet restore
|
||||
dotnet ef database update
|
||||
dotnet run
|
||||
```
|
||||
API 将在 `http://localhost:5057` 启动
|
||||
API 将在 `http://localhost:5173` 启动
|
||||
|
||||
#### 3. 启动前端 Web
|
||||
```bash
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<Application x:Class="TodoList.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:TodoList"
|
||||
ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,332 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using Microsoft.Win32;
|
||||
using TodoList.Services;
|
||||
using TodoList.ViewModels;
|
||||
using TodoList.Views;
|
||||
using System.Linq;
|
||||
|
||||
namespace TodoList
|
||||
{
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
private IDataService _dataService;
|
||||
private GlobalShortcutService _shortcutService;
|
||||
private MainWindow _mainWindow;
|
||||
private QuickEntryWindow? _quickEntryWindow;
|
||||
private SettingsService _settingsService;
|
||||
private System.Windows.Forms.NotifyIcon _notifyIcon;
|
||||
private Mutex _mutex;
|
||||
private EventWaitHandle _eventWaitHandle;
|
||||
private const string UniqueEventName = "Global\\TodoListApp_Event_v1";
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
public App()
|
||||
{
|
||||
// Disable hardware acceleration to prevent black screen issues
|
||||
// Must be set before any UI is created
|
||||
System.Windows.Media.RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.SoftwareOnly;
|
||||
}
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
// Ensure app doesn't shutdown when main window closes (we hide it)
|
||||
this.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
const string appName = "Global\\TodoListApp_Unique_Mutex_v1";
|
||||
bool createdNew;
|
||||
_mutex = new Mutex(true, appName, out createdNew);
|
||||
|
||||
if (!createdNew)
|
||||
{
|
||||
// Signal the existing instance
|
||||
try
|
||||
{
|
||||
using (var evt = EventWaitHandle.OpenExisting(UniqueEventName))
|
||||
{
|
||||
evt.Set();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fallback to old method if event open fails
|
||||
var hWnd = FindWindow(null, "待办事项");
|
||||
if (hWnd != IntPtr.Zero)
|
||||
{
|
||||
ShowWindow(hWnd, 9); // SW_RESTORE
|
||||
SetForegroundWindow(hWnd);
|
||||
}
|
||||
}
|
||||
|
||||
// Force exit to prevent second instance from running
|
||||
Environment.Exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the event handle for this instance
|
||||
_eventWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset, UniqueEventName);
|
||||
|
||||
// Start a thread to listen for signals
|
||||
Thread thread = new Thread(() =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
_eventWaitHandle.WaitOne();
|
||||
this.Dispatcher.Invoke(() => ShowMainWindow());
|
||||
}
|
||||
});
|
||||
thread.IsBackground = true;
|
||||
thread.Start();
|
||||
|
||||
base.OnStartup(e);
|
||||
|
||||
// Configure Auto Start
|
||||
ConfigureAutoStart();
|
||||
|
||||
// Create Desktop/StartMenu Shortcut if needed (optional feature)
|
||||
// CreateShortcut();
|
||||
|
||||
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
|
||||
try
|
||||
{
|
||||
_settingsService = new SettingsService();
|
||||
_dataService = new SqliteDataService();
|
||||
_shortcutService = new GlobalShortcutService();
|
||||
|
||||
var mainViewModel = new MainViewModel(_dataService, _settingsService);
|
||||
|
||||
_mainWindow = new MainWindow(mainViewModel);
|
||||
_mainWindow.Loaded += MainWindow_Loaded;
|
||||
|
||||
// Initialize Tray Icon
|
||||
InitializeTrayIcon();
|
||||
|
||||
// Check for silent mode
|
||||
bool silent = e.Args.Contains("--silent") || e.Args.Contains("-s");
|
||||
|
||||
if (!silent)
|
||||
{
|
||||
_mainWindow.Show();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log("Startup Error: " + ex.ToString());
|
||||
System.Windows.MessageBox.Show("Startup Error: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureAutoStart()
|
||||
{
|
||||
try
|
||||
{
|
||||
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
|
||||
// Usually %USERPROFILE%\.dotnet\tools\todo.exe
|
||||
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var toolShim = System.IO.Path.Combine(userProfile, ".dotnet", "tools", "todo.exe");
|
||||
|
||||
if (System.IO.File.Exists(toolShim))
|
||||
{
|
||||
// If the shim exists and we are likely running it (or just installed it), prefer the shim
|
||||
// This handles updates better as the shim path stays constant.
|
||||
// But we should verify if the current process IS related to it?
|
||||
// Actually, if installed as tool, we definitely want to use the shim.
|
||||
cmd = $"\"{toolShim}\" --silent";
|
||||
}
|
||||
|
||||
var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run", true);
|
||||
if (key != null)
|
||||
{
|
||||
var existing = key.GetValue("TodoListApp");
|
||||
if (existing == null || existing.ToString() != cmd)
|
||||
{
|
||||
key.SetValue("TodoListApp", cmd);
|
||||
}
|
||||
key.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log("AutoStart Error: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
_notifyIcon = new System.Windows.Forms.NotifyIcon();
|
||||
|
||||
// Try load icon from resource or file
|
||||
try
|
||||
{
|
||||
// Load from embedded resource
|
||||
var resourceUri = new Uri("pack://application:,,,/icon.ico");
|
||||
var streamInfo = System.Windows.Application.GetResourceStream(resourceUri);
|
||||
|
||||
if (streamInfo != null)
|
||||
{
|
||||
using (var stream = streamInfo.Stream)
|
||||
{
|
||||
_notifyIcon.Icon = new System.Drawing.Icon(stream);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_notifyIcon.Icon = System.Drawing.SystemIcons.Application;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
_notifyIcon.Icon = System.Drawing.SystemIcons.Application;
|
||||
}
|
||||
|
||||
_notifyIcon.Visible = true;
|
||||
_notifyIcon.Text = "TodoList";
|
||||
_notifyIcon.DoubleClick += (s, e) => ShowMainWindow();
|
||||
|
||||
var contextMenu = new System.Windows.Forms.ContextMenuStrip();
|
||||
contextMenu.Items.Add("打开主界面", null, (s, e) => ShowMainWindow());
|
||||
contextMenu.Items.Add("退出", null, (s, e) => ExitApplication());
|
||||
_notifyIcon.ContextMenuStrip = contextMenu;
|
||||
}
|
||||
|
||||
private void ShowMainWindow()
|
||||
{
|
||||
Log("ShowMainWindow called");
|
||||
if (_mainWindow != null)
|
||||
{
|
||||
if (_mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
_mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
_mainWindow.Show();
|
||||
_mainWindow.Activate();
|
||||
|
||||
// Force foreground
|
||||
var helper = new WindowInteropHelper(_mainWindow);
|
||||
var handle = helper.Handle;
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
SetForegroundWindow(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
_notifyIcon?.Dispose();
|
||||
_notifyIcon = null;
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
Log("Dispatcher Error: " + e.Exception.ToString());
|
||||
System.Windows.MessageBox.Show("Dispatcher Error: " + e.Exception.Message);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
|
||||
{
|
||||
Log("Domain Error: " + e.ExceptionObject.ToString());
|
||||
System.Windows.MessageBox.Show("Critical Error: " + e.ExceptionObject.ToString());
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.File.AppendAllText("error.log", DateTime.Now + ": " + message + Environment.NewLine);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private bool _isHotkeyRegistered;
|
||||
|
||||
private void RegisterHotkey()
|
||||
{
|
||||
if (_isHotkeyRegistered || _shortcutService == null || _settingsService == null) return;
|
||||
|
||||
var helper = new WindowInteropHelper(_mainWindow);
|
||||
var handle = helper.Handle;
|
||||
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
var settings = _settingsService.Settings;
|
||||
var mods = GlobalShortcutService.GetModifier(settings.ShortcutModifiers);
|
||||
var key = GlobalShortcutService.GetKey(settings.ShortcutKey);
|
||||
|
||||
_shortcutService.Register(handle, OnHotKeyPressed, mods, key);
|
||||
_isHotkeyRegistered = true;
|
||||
|
||||
// Subscribe to settings changes to update hotkey
|
||||
_settingsService.Settings.PropertyChanged += (s, args) =>
|
||||
{
|
||||
if (args.PropertyName == nameof(AppSettings.ShortcutModifiers) ||
|
||||
args.PropertyName == nameof(AppSettings.ShortcutKey))
|
||||
{
|
||||
var newMods = GlobalShortcutService.GetModifier(_settingsService.Settings.ShortcutModifiers);
|
||||
var newKey = GlobalShortcutService.GetKey(_settingsService.Settings.ShortcutKey);
|
||||
_shortcutService.UpdateShortcut(newMods, newKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHotKeyPressed()
|
||||
{
|
||||
Log("Hotkey pressed.");
|
||||
ShowQuickEntryWindow();
|
||||
}
|
||||
|
||||
private void ShowQuickEntryWindow()
|
||||
{
|
||||
if (_quickEntryWindow == null)
|
||||
{
|
||||
_quickEntryWindow = new QuickEntryWindow(_dataService);
|
||||
}
|
||||
|
||||
if (_quickEntryWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
_quickEntryWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_quickEntryWindow.Show();
|
||||
_quickEntryWindow.Activate();
|
||||
|
||||
var helper = new WindowInteropHelper(_quickEntryWindow);
|
||||
var handle = helper.Handle;
|
||||
if (handle != IntPtr.Zero)
|
||||
{
|
||||
SetForegroundWindow(handle);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
_notifyIcon?.Dispose();
|
||||
_shortcutService?.Dispose();
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
// We can hook into MainWindow's Loaded event to register hotkey
|
||||
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
RegisterHotkey();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace TodoList.Converters
|
||||
{
|
||||
public class EnumDescriptionConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value == null) return string.Empty;
|
||||
|
||||
var type = value.GetType();
|
||||
var name = Enum.GetName(type, value);
|
||||
if (name == null) return value.ToString();
|
||||
|
||||
var field = type.GetField(name);
|
||||
if (field == null) return value.ToString();
|
||||
|
||||
var attr = Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) as DescriptionAttribute;
|
||||
return attr?.Description ?? value.ToString();
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace TodoList.Models
|
||||
{
|
||||
public enum TodoPriority
|
||||
{
|
||||
[Description("低")]
|
||||
Low,
|
||||
[Description("中")]
|
||||
Medium,
|
||||
[Description("高")]
|
||||
High
|
||||
}
|
||||
|
||||
public enum SyncStatus
|
||||
{
|
||||
Synced,
|
||||
Pending,
|
||||
Failed
|
||||
}
|
||||
|
||||
public enum SortBy
|
||||
{
|
||||
[Description("创建时间")]
|
||||
CreatedAt,
|
||||
[Description("完成时间")]
|
||||
CompletedAt,
|
||||
[Description("优先级")]
|
||||
Priority
|
||||
}
|
||||
|
||||
public enum SortOrder
|
||||
{
|
||||
[Description("升序")]
|
||||
Ascending,
|
||||
[Description("降序")]
|
||||
Descending
|
||||
}
|
||||
|
||||
public partial class TodoItem : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
[property: SQLite.PrimaryKey]
|
||||
private string id = Guid.NewGuid().ToString();
|
||||
|
||||
[ObservableProperty]
|
||||
private string content = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCompleted;
|
||||
|
||||
[ObservableProperty]
|
||||
private TodoPriority priority = TodoPriority.Medium;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime createdAt = DateTime.Now;
|
||||
|
||||
[ObservableProperty]
|
||||
private DateTime? completedAt;
|
||||
|
||||
[ObservableProperty]
|
||||
private SyncStatus syncStatus = SyncStatus.Pending;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using TodoList.Models;
|
||||
|
||||
namespace TodoList.Services
|
||||
{
|
||||
public class FileDataService : IDataService
|
||||
{
|
||||
private readonly string _filePath;
|
||||
|
||||
public FileDataService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var folder = Path.Combine(appData, "TodoListApp");
|
||||
Directory.CreateDirectory(folder);
|
||||
_filePath = Path.Combine(folder, "tasks.json");
|
||||
}
|
||||
|
||||
public async Task<List<TodoItem>> LoadTasksAsync(bool? completed = null)
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return new List<TodoItem>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_filePath);
|
||||
var items = await JsonSerializer.DeserializeAsync<List<TodoItem>>(stream);
|
||||
var tasks = items ?? new List<TodoItem>();
|
||||
|
||||
if (completed.HasValue)
|
||||
{
|
||||
return tasks.Where(t => t.IsCompleted == completed.Value).ToList();
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<TodoItem>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TodoItem> SaveTaskAsync(TodoItem task)
|
||||
{
|
||||
var tasks = await LoadTasksAsync();
|
||||
var existing = tasks.Find(t => t.Id == task.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
tasks.Remove(existing);
|
||||
}
|
||||
tasks.Add(task);
|
||||
await SaveAllAsync(tasks);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TodoItem> UpdateTaskAsync(TodoItem task)
|
||||
{
|
||||
var tasks = await LoadTasksAsync();
|
||||
var existing = tasks.Find(t => t.Id == task.Id);
|
||||
if (existing != null)
|
||||
{
|
||||
tasks.Remove(existing);
|
||||
tasks.Add(task);
|
||||
await SaveAllAsync(tasks);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TodoItem> ToggleCompleteAsync(string id)
|
||||
{
|
||||
var tasks = await LoadTasksAsync();
|
||||
var task = tasks.Find(t => t.Id == id);
|
||||
if (task != null)
|
||||
{
|
||||
task.IsCompleted = !task.IsCompleted;
|
||||
task.CompletedAt = task.IsCompleted ? DateTime.Now : null;
|
||||
task.SyncStatus = SyncStatus.Pending;
|
||||
await SaveAllAsync(tasks);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task SaveAllAsync(List<TodoItem> tasks)
|
||||
{
|
||||
using var stream = File.Create(_filePath);
|
||||
await JsonSerializer.SerializeAsync(stream, tasks, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
public async Task DeleteTaskAsync(string id)
|
||||
{
|
||||
var tasks = await LoadTasksAsync();
|
||||
var existing = tasks.Find(t => t.Id == id);
|
||||
if (existing != null)
|
||||
{
|
||||
tasks.Remove(existing);
|
||||
await SaveAllAsync(tasks);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Interop;
|
||||
|
||||
namespace TodoList.Services
|
||||
{
|
||||
public class GlobalShortcutService : IDisposable
|
||||
{
|
||||
private const int HOTKEY_ID = 9000;
|
||||
|
||||
public const uint MOD_ALT = 0x0001;
|
||||
public const uint MOD_CONTROL = 0x0002;
|
||||
public const uint MOD_SHIFT = 0x0004;
|
||||
public const uint MOD_WIN = 0x0008;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
private IntPtr _windowHandle;
|
||||
private HwndSource _source;
|
||||
private Action _onHotKeyPressed;
|
||||
private bool _isRegistered;
|
||||
|
||||
public void Register(IntPtr windowHandle, Action onHotKeyPressed, uint modifiers, uint key)
|
||||
{
|
||||
// If already registered, unregister first (to support updating)
|
||||
if (_isRegistered)
|
||||
{
|
||||
UnregisterHotKey(_windowHandle, HOTKEY_ID);
|
||||
_source?.RemoveHook(HwndHook);
|
||||
_isRegistered = false;
|
||||
}
|
||||
|
||||
_windowHandle = windowHandle;
|
||||
_onHotKeyPressed = onHotKeyPressed;
|
||||
|
||||
_source = HwndSource.FromHwnd(_windowHandle);
|
||||
if (_source == null) return; // Should not happen if handle is valid
|
||||
|
||||
_source.AddHook(HwndHook);
|
||||
|
||||
if (RegisterHotKey(_windowHandle, HOTKEY_ID, modifiers, key))
|
||||
{
|
||||
_isRegistered = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine("Failed to register hotkey.");
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateShortcut(uint modifiers, uint key)
|
||||
{
|
||||
if (_windowHandle != IntPtr.Zero && _onHotKeyPressed != null)
|
||||
{
|
||||
// Re-register with new keys
|
||||
Register(_windowHandle, _onHotKeyPressed, modifiers, key);
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
const int WM_HOTKEY = 0x0312;
|
||||
if (msg == WM_HOTKEY)
|
||||
{
|
||||
if (wParam.ToInt32() == HOTKEY_ID)
|
||||
{
|
||||
_onHotKeyPressed?.Invoke();
|
||||
handled = true;
|
||||
}
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
public void Unregister()
|
||||
{
|
||||
if (_isRegistered)
|
||||
{
|
||||
_source?.RemoveHook(HwndHook);
|
||||
UnregisterHotKey(_windowHandle, HOTKEY_ID);
|
||||
_isRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Unregister();
|
||||
}
|
||||
|
||||
public static uint GetModifier(string modifiers)
|
||||
{
|
||||
uint mod = 0;
|
||||
if (string.IsNullOrEmpty(modifiers)) return mod;
|
||||
|
||||
var parts = modifiers.Split(',');
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var p = part.Trim();
|
||||
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= MOD_CONTROL;
|
||||
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= MOD_ALT;
|
||||
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= MOD_SHIFT;
|
||||
if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= MOD_WIN;
|
||||
}
|
||||
return mod;
|
||||
}
|
||||
|
||||
public static uint GetKey(string key)
|
||||
{
|
||||
if (Enum.TryParse<Key>(key, out var k))
|
||||
{
|
||||
return (uint)KeyInterop.VirtualKeyFromKey(k);
|
||||
}
|
||||
// Fallback for simple letters if Key enum doesn't match directly (though it should for A-Z)
|
||||
if (key.Length == 1)
|
||||
{
|
||||
char c = char.ToUpper(key[0]);
|
||||
if (c >= 'A' && c <= 'Z') return (uint)c;
|
||||
if (c >= '0' && c <= '9') return (uint)c;
|
||||
}
|
||||
return 0x41; // Default 'A'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using TodoList.Models;
|
||||
|
||||
namespace TodoList.Services
|
||||
{
|
||||
public interface IDataService
|
||||
{
|
||||
Task<List<TodoItem>> LoadTasksAsync(bool? completed = null);
|
||||
Task<TodoItem> SaveTaskAsync(TodoItem task);
|
||||
Task<TodoItem> UpdateTaskAsync(TodoItem task);
|
||||
Task<TodoItem> ToggleCompleteAsync(string id);
|
||||
Task DeleteTaskAsync(string id);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace TodoList.Services
|
||||
{
|
||||
public partial class AppSettings : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string shortcutModifiers = "Control,Alt"; // Comma separated
|
||||
|
||||
[ObservableProperty]
|
||||
private string shortcutKey = "A";
|
||||
}
|
||||
|
||||
public class SettingsService
|
||||
{
|
||||
private readonly string _filePath;
|
||||
public AppSettings Settings { get; private set; }
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var folder = Path.Combine(appData, "TodoListApp");
|
||||
Directory.CreateDirectory(folder);
|
||||
_filePath = Path.Combine(folder, "settings.json");
|
||||
Settings = LoadSettings();
|
||||
}
|
||||
|
||||
private AppSettings LoadSettings()
|
||||
{
|
||||
if (!File.Exists(_filePath)) return new AppSettings();
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_filePath);
|
||||
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(Settings, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using SQLite;
|
||||
using TodoList.Models;
|
||||
|
||||
namespace TodoList.Services
|
||||
{
|
||||
public class SqliteDataService : IDataService
|
||||
{
|
||||
private readonly SQLiteAsyncConnection _database;
|
||||
|
||||
public SqliteDataService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
var folder = Path.Combine(appData, "TodoListApp");
|
||||
Directory.CreateDirectory(folder);
|
||||
var databasePath = Path.Combine(folder, "TodoList.sqlite");
|
||||
|
||||
_database = new SQLiteAsyncConnection(databasePath);
|
||||
_database.CreateTableAsync<TodoItem>().Wait();
|
||||
}
|
||||
|
||||
public async Task<List<TodoItem>> LoadTasksAsync(bool? completed = null)
|
||||
{
|
||||
var query = _database.Table<TodoItem>();
|
||||
if (completed.HasValue)
|
||||
{
|
||||
query = query.Where(t => t.IsCompleted == completed.Value);
|
||||
}
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<TodoItem> SaveTaskAsync(TodoItem task)
|
||||
{
|
||||
await _database.InsertOrReplaceAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TodoItem> UpdateTaskAsync(TodoItem task)
|
||||
{
|
||||
await _database.UpdateAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<TodoItem> ToggleCompleteAsync(string id)
|
||||
{
|
||||
var task = await _database.FindAsync<TodoItem>(id);
|
||||
if (task != null)
|
||||
{
|
||||
task.IsCompleted = !task.IsCompleted;
|
||||
task.CompletedAt = task.IsCompleted ? DateTime.Now : null;
|
||||
task.SyncStatus = SyncStatus.Pending;
|
||||
await _database.UpdateAsync(task);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task SaveAllAsync(List<TodoItem> tasks)
|
||||
{
|
||||
await _database.RunInTransactionAsync(tran =>
|
||||
{
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
tran.InsertOrReplace(task);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task DeleteTaskAsync(string id)
|
||||
{
|
||||
await _database.DeleteAsync<TodoItem>(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>icon.ico</ApplicationIcon>
|
||||
<Version>1.0.21</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
||||
<PackageReference Include="System.Net.Http.Json" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="icon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using TodoList.Models;
|
||||
using TodoList.Services;
|
||||
|
||||
namespace TodoList.ViewModels
|
||||
{
|
||||
public partial class MainViewModel : ObservableObject, IRecipient<TaskAddedMessage>
|
||||
{
|
||||
private readonly IDataService _dataService;
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<TodoItem> tasks = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private bool showCompleted = false;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newContent;
|
||||
|
||||
[ObservableProperty]
|
||||
private TodoPriority newPriority = TodoPriority.Medium;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEditDialogOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private TodoItem editingTask;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editContent;
|
||||
|
||||
[ObservableProperty]
|
||||
private TodoPriority editPriority = TodoPriority.Medium;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSettingsOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FullShortcut))]
|
||||
private string shortcutKey;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(FullShortcut))]
|
||||
private string shortcutModifiers;
|
||||
|
||||
[ObservableProperty]
|
||||
private SortBy sortBy = SortBy.Priority;
|
||||
|
||||
[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
|
||||
{
|
||||
var mods = ShortcutModifiers?.Replace(",", " + ");
|
||||
return string.IsNullOrEmpty(mods) ? ShortcutKey : $"{mods} + {ShortcutKey}";
|
||||
}
|
||||
}
|
||||
|
||||
public MainViewModel(IDataService dataService, SettingsService settingsService)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_settingsService = settingsService;
|
||||
ShortcutKey = _settingsService.Settings.ShortcutKey;
|
||||
ShortcutModifiers = _settingsService.Settings.ShortcutModifiers;
|
||||
|
||||
WeakReferenceMessenger.Default.Register(this);
|
||||
LoadTasksCommand.Execute(null);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenSettings()
|
||||
{
|
||||
IsSettingsOpen = true;
|
||||
ShortcutKey = _settingsService.Settings.ShortcutKey;
|
||||
ShortcutModifiers = _settingsService.Settings.ShortcutModifiers;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseSettings()
|
||||
{
|
||||
IsSettingsOpen = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SaveSettings()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ShortcutKey))
|
||||
{
|
||||
_settingsService.Settings.ShortcutKey = ShortcutKey.ToUpper();
|
||||
_settingsService.Settings.ShortcutModifiers = ShortcutModifiers;
|
||||
_settingsService.SaveSettings();
|
||||
}
|
||||
IsSettingsOpen = false;
|
||||
}
|
||||
|
||||
async partial void OnShowCompletedChanged(bool value)
|
||||
{
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
|
||||
async partial void OnSortByChanged(SortBy value)
|
||||
{
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
|
||||
async partial void OnSortOrderChanged(Models.SortOrder value)
|
||||
{
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddTaskAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(NewContent)) return;
|
||||
|
||||
var newTask = new TodoItem
|
||||
{
|
||||
Content = NewContent,
|
||||
Priority = NewPriority,
|
||||
IsCompleted = false,
|
||||
SyncStatus = SyncStatus.Pending
|
||||
};
|
||||
|
||||
await _dataService.SaveTaskAsync(newTask);
|
||||
NewContent = string.Empty;
|
||||
NewPriority = TodoPriority.Medium;
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadTasksAsync()
|
||||
{
|
||||
var allTasks = await _dataService.LoadTasksAsync();
|
||||
|
||||
var filtered = ShowCompleted
|
||||
? allTasks
|
||||
: allTasks.Where(t => !t.IsCompleted).ToList();
|
||||
|
||||
IOrderedEnumerable<TodoItem> sorted;
|
||||
|
||||
if (SortBy == SortBy.Priority)
|
||||
{
|
||||
sorted = SortOrder == Models.SortOrder.Ascending
|
||||
? filtered.OrderBy(t => t.Priority).ThenBy(t => t.CreatedAt)
|
||||
: filtered.OrderByDescending(t => t.Priority).ThenBy(t => t.CreatedAt);
|
||||
}
|
||||
else if (SortBy == SortBy.CreatedAt)
|
||||
{
|
||||
sorted = SortOrder == Models.SortOrder.Ascending
|
||||
? filtered.OrderBy(t => t.CreatedAt)
|
||||
: filtered.OrderByDescending(t => t.CreatedAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
sorted = SortOrder == Models.SortOrder.Ascending
|
||||
? filtered.OrderBy(t => t.CompletedAt).ThenBy(t => t.CreatedAt)
|
||||
: filtered.OrderByDescending(t => t.CompletedAt).ThenBy(t => t.CreatedAt);
|
||||
}
|
||||
|
||||
Tasks.Clear();
|
||||
foreach (var t in sorted)
|
||||
{
|
||||
Tasks.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleCompleteAsync(TodoItem item)
|
||||
{
|
||||
if (item == null) return;
|
||||
|
||||
if (item.IsCompleted)
|
||||
{
|
||||
item.CompletedAt = DateTime.Now;
|
||||
}
|
||||
else
|
||||
{
|
||||
item.CompletedAt = null;
|
||||
}
|
||||
|
||||
item.SyncStatus = SyncStatus.Pending;
|
||||
await _dataService.SaveTaskAsync(item);
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync(TodoItem item)
|
||||
{
|
||||
if (item == null) return;
|
||||
await _dataService.DeleteTaskAsync(item.Id);
|
||||
Tasks.Remove(item);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenEditDialog(TodoItem item)
|
||||
{
|
||||
if (item == null) return;
|
||||
EditingTask = item;
|
||||
EditContent = item.Content;
|
||||
EditPriority = item.Priority;
|
||||
IsEditDialogOpen = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseEditDialog()
|
||||
{
|
||||
IsEditDialogOpen = false;
|
||||
EditingTask = null;
|
||||
EditContent = string.Empty;
|
||||
EditPriority = TodoPriority.Medium;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveEditAsync()
|
||||
{
|
||||
if (EditingTask == null || string.IsNullOrWhiteSpace(EditContent)) return;
|
||||
|
||||
EditingTask.Content = EditContent;
|
||||
EditingTask.Priority = EditPriority;
|
||||
EditingTask.SyncStatus = SyncStatus.Pending;
|
||||
|
||||
await _dataService.SaveTaskAsync(EditingTask);
|
||||
await LoadTasksAsync();
|
||||
CloseEditDialog();
|
||||
}
|
||||
|
||||
public async void Receive(TaskAddedMessage message)
|
||||
{
|
||||
await LoadTasksAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public class TaskAddedMessage
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using TodoList.Models;
|
||||
using TodoList.Services;
|
||||
|
||||
namespace TodoList.ViewModels
|
||||
{
|
||||
public partial class QuickEntryViewModel : ObservableObject
|
||||
{
|
||||
private readonly IDataService _dataService;
|
||||
private Action _closeAction;
|
||||
|
||||
[ObservableProperty]
|
||||
private string content;
|
||||
|
||||
[ObservableProperty]
|
||||
private TodoPriority priority = TodoPriority.Medium;
|
||||
|
||||
public QuickEntryViewModel(IDataService dataService, Action closeAction)
|
||||
{
|
||||
_dataService = dataService;
|
||||
_closeAction = closeAction;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Content)) return;
|
||||
|
||||
var newTask = new TodoItem
|
||||
{
|
||||
Content = Content,
|
||||
Priority = Priority,
|
||||
IsCompleted = false,
|
||||
SyncStatus = SyncStatus.Pending
|
||||
};
|
||||
|
||||
await _dataService.SaveTaskAsync(newTask);
|
||||
|
||||
// Notify MainViewModel
|
||||
WeakReferenceMessenger.Default.Send(new TaskAddedMessage());
|
||||
|
||||
// Reset and close
|
||||
Content = string.Empty;
|
||||
Priority = TodoPriority.Medium;
|
||||
_closeAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
_closeAction?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
<Window x:Class="TodoList.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:TodoList.Views"
|
||||
xmlns:models="clr-namespace:TodoList.Models"
|
||||
xmlns:converters="clr-namespace:TodoList.Converters"
|
||||
mc:Ignorable="d"
|
||||
Title="{Binding AppVersion, StringFormat='待办事项 v{0}'}" Height="450" Width="350"
|
||||
Background="#F5F5F7"
|
||||
Icon="/icon.ico"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
PreviewKeyDown="Window_PreviewKeyDown">
|
||||
|
||||
<Window.Resources>
|
||||
<ObjectDataProvider x:Key="PriorityEnum" MethodName="GetValues"
|
||||
ObjectType="{x:Type sys:Enum}"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
<ObjectDataProvider.MethodParameters>
|
||||
<x:Type TypeName="models:TodoPriority"/>
|
||||
</ObjectDataProvider.MethodParameters>
|
||||
</ObjectDataProvider>
|
||||
|
||||
<ObjectDataProvider x:Key="SortByEnum" MethodName="GetValues"
|
||||
ObjectType="{x:Type sys:Enum}"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
<ObjectDataProvider.MethodParameters>
|
||||
<x:Type TypeName="models:SortBy"/>
|
||||
</ObjectDataProvider.MethodParameters>
|
||||
</ObjectDataProvider>
|
||||
|
||||
<ObjectDataProvider x:Key="SortOrderEnum" MethodName="GetValues"
|
||||
ObjectType="{x:Type sys:Enum}"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
<ObjectDataProvider.MethodParameters>
|
||||
<x:Type TypeName="models:SortOrder"/>
|
||||
</ObjectDataProvider.MethodParameters>
|
||||
</ObjectDataProvider>
|
||||
|
||||
<Style x:Key="ModernButton" TargetType="Button">
|
||||
<Setter Property="Background" Value="#007AFF"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="5">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="#0062CC"/>
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
|
||||
<converters:EnumDescriptionConverter x:Key="EnumDescConverter"/>
|
||||
</Window.Resources>
|
||||
|
||||
<!-- Force Rebuild Trigger -->
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/> <!-- Toolbar -->
|
||||
<RowDefinition Height="Auto"/> <!-- Input -->
|
||||
<RowDefinition Height="*"/> <!-- List -->
|
||||
<RowDefinition Height="Auto"/> <!-- Footer -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Header / Toolbar -->
|
||||
<Border Grid.Row="0" Background="White" Padding="10" Effect="{DynamicResource {x:Static DropShadowEffect.ShadowDepthProperty}}">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="5,0,10,0" VerticalAlignment="Center">
|
||||
<ComboBox ItemsSource="{Binding Source={StaticResource SortByEnum}}"
|
||||
SelectedItem="{Binding SortBy}"
|
||||
Width="70" VerticalContentAlignment="Center" Margin="0,0,3,0">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<ComboBox ItemsSource="{Binding Source={StaticResource SortOrderEnum}}"
|
||||
SelectedItem="{Binding SortOrder}"
|
||||
Width="50" VerticalContentAlignment="Center">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Content="设置快捷键"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
Background="Transparent" Foreground="#007AFF" Margin="0,0,10,0" FontSize="11">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
|
||||
<CheckBox Grid.Column="3" Content="显示已完成"
|
||||
IsChecked="{Binding ShowCompleted}"
|
||||
VerticalAlignment="Center" Foreground="#555" FontSize="11"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Input Area -->
|
||||
<Border Grid.Row="1" Margin="10" Background="White" CornerRadius="6" Padding="8">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBox Text="{Binding NewContent, UpdateSourceTrigger=PropertyChanged}"
|
||||
FontSize="12" Padding="4" Margin="0,0,8,0" BorderThickness="0,0,0,1"
|
||||
VerticalContentAlignment="Center"
|
||||
Tag="添加新任务...">
|
||||
<TextBox.Style>
|
||||
<Style TargetType="TextBox">
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Text" Value="">
|
||||
<!-- Placeholder could be done with a visual brush or adornment, keeping it simple for now -->
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBox.Style>
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding AddTaskCommand}"/>
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Source={StaticResource PriorityEnum}}"
|
||||
SelectedItem="{Binding NewPriority}"
|
||||
Width="65" Margin="0,0,8,0" VerticalContentAlignment="Center">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<Button Grid.Column="2" Content="添加" Command="{Binding AddTaskCommand}" Style="{StaticResource ModernButton}"
|
||||
VerticalAlignment="Center" Height="28" Width="65" FontSize="12"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Task List -->
|
||||
<ListBox Grid.Row="2" ItemsSource="{Binding Tasks}"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
Margin="10,0,10,10"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Background" Value="White"/>
|
||||
<Setter Property="Margin" Value="0,0,0,6"/>
|
||||
<Setter Property="Padding" Value="8"/>
|
||||
<EventSetter Event="MouseDoubleClick" Handler="ListBoxItem_MouseDoubleClick"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="ListBoxItem">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="8" Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Priority}" Value="High">
|
||||
<Setter Property="Background" Value="#FFCDD2"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Priority}" Value="Medium">
|
||||
<Setter Property="Background" Value="#FFE0B2"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Priority}" Value="Low">
|
||||
<Setter Property="Background" Value="#C8E6C9"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<CheckBox IsChecked="{Binding IsCompleted}"
|
||||
Command="{Binding DataContext.ToggleCompleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
VerticalAlignment="Center">
|
||||
<CheckBox.LayoutTransform>
|
||||
<ScaleTransform ScaleX="1.0" ScaleY="1.0"/>
|
||||
</CheckBox.LayoutTransform>
|
||||
</CheckBox>
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="10,0">
|
||||
<TextBlock Text="{Binding Content}" FontSize="13" VerticalAlignment="Center">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
|
||||
<Setter Property="TextDecorations" Value="Strikethrough"/>
|
||||
<Setter Property="Foreground" Value="#999"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding IsCompleted}" Value="False">
|
||||
<Setter Property="Foreground" Value="#333"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Priority, Converter={StaticResource EnumDescConverter}}" FontSize="10" Foreground="#888" Margin="0,1,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2" Content="✎"
|
||||
Command="{Binding DataContext.OpenEditDialogCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
Width="20" Height="20"
|
||||
Background="Transparent" Foreground="#007AFF"
|
||||
BorderThickness="0" FontSize="10" FontWeight="Bold"
|
||||
Cursor="Hand" Margin="0,0,4,0">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="border" Background="{TemplateBinding Background}" CornerRadius="12">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="border" Property="Background" Value="#1A007AFF"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" Content="✕"
|
||||
Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"
|
||||
CommandParameter="{Binding}"
|
||||
Width="20" Height="20"
|
||||
Background="Transparent" Foreground="#FF3B30"
|
||||
BorderThickness="0" FontSize="10" FontWeight="Bold"
|
||||
Cursor="Hand">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="border" Background="{TemplateBinding Background}" CornerRadius="12">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="border" Property="Background" Value="#1AFF3B30"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
|
||||
<!-- Edit Dialog Overlay -->
|
||||
<Grid Grid.RowSpan="3" Background="#80000000" Visibility="{Binding IsEditDialogOpen, Converter={StaticResource BoolToVis}}">
|
||||
<Border Background="White" Width="300" Height="220" CornerRadius="8" VerticalAlignment="Center" HorizontalAlignment="Center" Padding="15">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="编辑任务" FontSize="15" FontWeight="Bold" HorizontalAlignment="Center" Margin="0,0,0,12"/>
|
||||
|
||||
<StackPanel Grid.Row="1" VerticalAlignment="Center">
|
||||
<TextBlock Text="任务内容" Foreground="#666" Margin="0,0,0,4" FontSize="10"/>
|
||||
<TextBox Text="{Binding EditContent, UpdateSourceTrigger=PropertyChanged}"
|
||||
FontSize="12" Padding="6" Margin="0,0,0,12" BorderThickness="1" BorderBrush="#DDD"
|
||||
VerticalContentAlignment="Center"/>
|
||||
|
||||
<TextBlock Text="优先级" Foreground="#666" Margin="0,0,0,4" FontSize="10"/>
|
||||
<ComboBox ItemsSource="{Binding Source={StaticResource PriorityEnum}}"
|
||||
SelectedItem="{Binding EditPriority}"
|
||||
Width="260" VerticalContentAlignment="Center" BorderThickness="1" BorderBrush="#DDD" FontSize="11">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,12,0,0">
|
||||
<Button Content="取消" Command="{Binding CloseEditDialogCommand}" Width="70" Margin="0,0,8,0"
|
||||
Background="#EEE" Foreground="#333" Height="26" BorderThickness="0" FontSize="11">
|
||||
<Button.Template>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="4">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Button.Template>
|
||||
</Button>
|
||||
<Button Content="保存" Command="{Binding SaveEditCommand}" Width="70" Height="26" Style="{StaticResource ModernButton}" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Footer -->
|
||||
<Border Grid.Row="3" Background="#F5F5F7" Padding="10,8" HorizontalAlignment="Center">
|
||||
<TextBlock FontSize="10">
|
||||
<Hyperlink NavigateUri="https://github.com/xinshoushangdao/TodoList" RequestNavigate="Hyperlink_RequestNavigate">
|
||||
<Run Text="GitHub Repository"/>
|
||||
</Hyperlink>
|
||||
</TextBlock>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,96 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using TodoList.ViewModels;
|
||||
|
||||
namespace TodoList.Views
|
||||
{
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow(MainViewModel viewModel)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = viewModel;
|
||||
}
|
||||
|
||||
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
e.Cancel = true;
|
||||
this.Hide();
|
||||
// Verify if app shuts down? No, ShutdownMode is Explicit.
|
||||
}
|
||||
|
||||
private void Window_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Escape)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
vm.IsSettingsOpen = false;
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Default behavior: Close (Hide) Window
|
||||
Close();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShortcutBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
|
||||
// Ignore modifier keys alone being the "main" key
|
||||
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl ||
|
||||
e.Key == Key.LeftAlt || e.Key == Key.RightAlt ||
|
||||
e.Key == Key.LeftShift || e.Key == Key.RightShift ||
|
||||
e.Key == Key.LWin || e.Key == Key.RWin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var key = e.Key;
|
||||
if (key == Key.System) key = e.SystemKey; // Handle Alt+Key
|
||||
|
||||
// Build modifier string
|
||||
var modifiers = new System.Collections.Generic.List<string>();
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control) modifiers.Add("Control");
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Alt) == ModifierKeys.Alt) modifiers.Add("Alt");
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift) modifiers.Add("Shift");
|
||||
if ((Keyboard.Modifiers & ModifierKeys.Windows) == ModifierKeys.Windows) modifiers.Add("Windows");
|
||||
|
||||
// Map key to string
|
||||
string keyStr = key.ToString();
|
||||
|
||||
// Simple mapping for letters/digits (A-Z, 0-9)
|
||||
if (keyStr.Length == 2 && keyStr.StartsWith("D") && char.IsDigit(keyStr[1]))
|
||||
{
|
||||
keyStr = keyStr.Substring(1);
|
||||
}
|
||||
|
||||
// Update ViewModel
|
||||
if (DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.ShortcutModifiers = string.Join(",", modifiers);
|
||||
vm.ShortcutKey = keyStr;
|
||||
}
|
||||
}
|
||||
|
||||
private void ListBoxItem_MouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is ListBoxItem item && item.DataContext is Models.TodoItem todoItem && DataContext is MainViewModel vm)
|
||||
{
|
||||
vm.OpenEditDialogCommand.Execute(todoItem);
|
||||
}
|
||||
}
|
||||
|
||||
private void Hyperlink_RequestNavigate(object sender, System.Windows.Navigation.RequestNavigateEventArgs e)
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
<Window x:Class="TodoList.Views.QuickEntryWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:TodoList.Views"
|
||||
xmlns:models="clr-namespace:TodoList.Models"
|
||||
xmlns:converters="clr-namespace:TodoList.Converters"
|
||||
mc:Ignorable="d"
|
||||
Title="新建待办" Height="180" Width="320"
|
||||
WindowStyle="None" ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Topmost="True"
|
||||
Background="White"
|
||||
BorderBrush="#007AFF" BorderThickness="1">
|
||||
|
||||
<Window.Effect>
|
||||
<DropShadowEffect BlurRadius="20" ShadowDepth="5" Opacity="0.3"/>
|
||||
</Window.Effect>
|
||||
|
||||
<Window.Resources>
|
||||
<ObjectDataProvider x:Key="PriorityEnum" MethodName="GetValues"
|
||||
ObjectType="{x:Type sys:Enum}"
|
||||
xmlns:sys="clr-namespace:System;assembly=mscorlib">
|
||||
<ObjectDataProvider.MethodParameters>
|
||||
<x:Type TypeName="models:TodoPriority"/>
|
||||
</ObjectDataProvider.MethodParameters>
|
||||
</ObjectDataProvider>
|
||||
<converters:EnumDescriptionConverter x:Key="EnumDescConverter"/>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="5" BorderThickness="0">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid Margin="15">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="新建待办" FontSize="15" FontWeight="Bold" Foreground="#333"/>
|
||||
|
||||
<TextBox Grid.Row="1" Margin="0,12,0,12"
|
||||
Text="{Binding Content, UpdateSourceTrigger=PropertyChanged}"
|
||||
FontSize="12" Padding="6" BorderThickness="0,0,0,1"
|
||||
x:Name="InputBox">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding SaveCommand}"/>
|
||||
<KeyBinding Key="Esc" Command="{Binding CancelCommand}"/>
|
||||
</TextBox.InputBindings>
|
||||
</TextBox>
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" VerticalAlignment="Top">
|
||||
<TextBlock Text="优先级:" VerticalAlignment="Center" Margin="0,0,8,0" Foreground="#666" FontSize="11"/>
|
||||
<ComboBox ItemsSource="{Binding Source={StaticResource PriorityEnum}}"
|
||||
SelectedItem="{Binding Priority}"
|
||||
Width="80" FontSize="11">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="取消" Command="{Binding CancelCommand}" Margin="0,0,8,0" Width="65" Height="26"
|
||||
Background="#F0F0F0" Foreground="#333" FontSize="11"/>
|
||||
<Button Content="保存" Command="{Binding SaveCommand}" Width="65" Height="26" IsDefault="True"
|
||||
Background="#007AFF" Foreground="#White" FontSize="11"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Windows;
|
||||
using TodoList.Services;
|
||||
using TodoList.ViewModels;
|
||||
|
||||
namespace TodoList.Views
|
||||
{
|
||||
public partial class QuickEntryWindow : Window
|
||||
{
|
||||
public QuickEntryWindow(IDataService dataService)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new QuickEntryViewModel(dataService, () => this.Hide());
|
||||
}
|
||||
|
||||
protected override void OnActivated(EventArgs e)
|
||||
{
|
||||
base.OnActivated(e);
|
||||
InputBox.Focus();
|
||||
InputBox.SelectAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB |
@@ -1,56 +0,0 @@
|
||||
#define MyAppName "TodoList"
|
||||
#define MyAppVersion "1.0.21"
|
||||
#define MyAppPublisher "ShaoHua"
|
||||
#define MyAppURL "https://git.we965.cn/Tools/TodoList"
|
||||
#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={{8B8A6E3F-1234-5678-9ABC-DEF012345678}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
;AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
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=icon.ico
|
||||
Compression=lzma
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "bin\Release\net8.0-windows\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Android 端显示 “Not Found” 排查计划(TodoList.Maui)
|
||||
|
||||
## 目标
|
||||
|
||||
- 找出 Android 模拟器里只显示 `Not Found` 的根因(是 Web 资源缺失、内嵌 Web Server 路由/解析问题,还是 WebView 加载了错误地址)
|
||||
- 给出可验证的修复方案,并确保修复后能在 Android 上正常加载前端页面
|
||||
|
||||
## 背景(当前实现快速定位)
|
||||
|
||||
- Android 使用自建 TCP HTTP Server,静态资源从 APK 的 `Assets/wwwroot/*` 读取:[MobileEmbeddedWebServerService](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs)
|
||||
- WebView 默认加载内嵌服务器地址(`IsUsingStatic=true` 时):[MainPage.xaml.cs](file:///d:/Proj/TodoList/src/TodoList.Maui/Views/MainPage.xaml.cs)
|
||||
- Android 端静态文件找不到时返回纯文本 `Not Found`:[HandleStaticAsync](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs#L214-L255)
|
||||
|
||||
## 排查顺序(从“最可能 & 最省时间”到“深入原因”)
|
||||
|
||||
### 1) 确认 WebView 实际加载的 URL
|
||||
|
||||
- 在 Android Debug 输出里确认 WebView Source(期望是 `http://localhost:5057` 或 `http://localhost:5057/`)
|
||||
- 如果不是内嵌地址,检查 `appsettings.json` 的 `WebServer.IsUsingStatic` 与 `ForEndUrl` 配置:[appsettings.json](file:///d:/Proj/TodoList/src/TodoList.Maui/appsettings.json)
|
||||
|
||||
判定:
|
||||
- 若加载的是内嵌地址 → 继续第 2 步
|
||||
- 若加载的是外部地址(ForEndUrl)→ 重点查 ForEndUrl 对应服务是否启动/路由是否正确
|
||||
|
||||
### 2) 确认前端 dist 是否存在且可用于打包
|
||||
|
||||
- 检查 `src/TodoList.Web/dist/index.html` 是否存在
|
||||
- 如果不存在:在 `src/TodoList.Web` 下执行 `npm ci` + `npm run build`,确保产物生成
|
||||
|
||||
判定:
|
||||
- dist 不存在/为空 → “Not Found”高概率来自 Android 静态资源根本没被构建或没被打进 APK
|
||||
|
||||
### 3) 确认 Android APK 内是否真的包含 `Assets/wwwroot/index.html`
|
||||
|
||||
- 重点验证打包结果是否存在:
|
||||
- `assets/wwwroot/index.html`
|
||||
- `assets/wwwroot/assets/*`(至少有 js/css)
|
||||
- 项目里通过 MSBuild 目标把 `TodoList.Web/dist` 映射为 AndroidAsset(Link 到 `wwwroot/...`):[TodoList.Maui.csproj](file:///d:/Proj/TodoList/src/TodoList.Maui/TodoList.Maui.csproj#L150-L175)
|
||||
|
||||
判定:
|
||||
- APK 内没有 `wwwroot/index.html` → 修复构建/打包流程(第 6 步会给方案)
|
||||
- APK 内有 `wwwroot/index.html` → 继续第 4 步
|
||||
|
||||
### 4) 记录 Android 内嵌服务器的“收到的请求 Path”与“找不到的 assetPath”
|
||||
|
||||
目的:判断是否是“请求行解析不兼容”或“路径格式异常”导致找不到资源。
|
||||
|
||||
- 在 `ReadRequestAsync`、`HandleStaticAsync` 临时输出:
|
||||
- requestLine / target / path
|
||||
- 计算出的 assetPath
|
||||
- TryOpenAsset 失败的 assetPath
|
||||
|
||||
高频根因候选:
|
||||
- WebView 请求行使用 absolute-form(例如 `GET http://localhost:5057/ HTTP/1.1`),当前解析逻辑会把整个 URL 当作 path,最终拼成无效 `wwwroothttp://...`,导致 404
|
||||
|
||||
### 5) 排除 WebView/网络限制类问题(只在必要时做)
|
||||
|
||||
- 如果看到的不是纯文本 `Not Found`,而是加载错误/空白:
|
||||
- 检查 Android 明文 HTTP(`http://localhost`)是否被允许
|
||||
- 检查 `network_security_config.xml` 与 Manifest 配置:[network_security_config.xml](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/Resources/xml/network_security_config.xml)、[AndroidManifest.xml](file:///d:/Proj/TodoList/src/TodoList.Maui/Platforms/Android/AndroidManifest.xml)
|
||||
|
||||
### 6) 修复与验证(根据前面判定选择)
|
||||
|
||||
#### A. 资源缺失/未打包
|
||||
|
||||
- 让构建流程更“硬性”:
|
||||
- 若 dist 不存在则强制构建,或在 Debug 也保证 `AndroidAsset` 包含 dist
|
||||
- 可选:把 dist 复制进 `TodoList.Maui/wwwroot` 再用 `<Content Include="wwwroot\**" />`/`<MauiAsset />` 统一打包(减少条件目标的不确定性)
|
||||
|
||||
验证:
|
||||
- APK 内能看到 `assets/wwwroot/index.html`,启动后不再返回 `Not Found`
|
||||
|
||||
#### B. 请求路径解析不兼容(absolute-form 等)
|
||||
|
||||
- 改进 `ReadRequestAsync`:当 target 是 `http(s)://...` 时解析出其中的 Path + Query,再走现有逻辑
|
||||
|
||||
验证:
|
||||
- 记录到的 path 变为 `/` 或 `/index.html`,能成功打开 `wwwroot/index.html`
|
||||
|
||||
#### C. 资源引用路径问题(js/css 请求 404)
|
||||
|
||||
- 检查 dist 中 `index.html` 对 `assets/*` 的引用路径是否与 AndroidAsset 的 Link 一致
|
||||
- 若 Vite 输出含子目录(例如 `assets/chunks/...`),需要在 csproj 里用 `dist\**\*` 并保留 `%(RecursiveDir)`,避免扁平化导致引用断裂
|
||||
|
||||
验证:
|
||||
- WebView 网络请求里 js/css 全部 200,页面正常渲染
|
||||
|
||||
## 本次排查的“最短闭环”
|
||||
|
||||
- 先确认 dist 是否存在 + APK 是否包含 `assets/wwwroot/index.html`
|
||||
- 若存在仍 Not Found,再用日志确认 requestLine/target/path 是否被解析成异常值(absolute-form 是最高优先级怀疑点)
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@
|
||||
## 当前运行状态
|
||||
|
||||
### 服务状态
|
||||
- ✅ **TodoList.Api**: 运行中 (http://localhost:5057)
|
||||
- ✅ **TodoList.Api**: 运行中 (http://localhost:5173)
|
||||
- ✅ **TodoList.Web**: 运行中 (http://localhost:5173)
|
||||
- ✅ **TodoList.Maui**: 运行中 (Windows 桌面应用)
|
||||
|
||||
|
||||
@@ -1,47 +1,46 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Basic configuration
|
||||
$ScriptPath = $PSScriptRoot
|
||||
$ProjectFile = (Get-ChildItem -Path $ScriptPath -Filter "*.csproj" -File)[0].FullName
|
||||
$SetupScript = Join-Path $ScriptPath "setup.iss"
|
||||
$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
|
||||
if ($csproj.Project.PropertyGroup.Version) {
|
||||
$currentVersion = $csproj.Project.PropertyGroup.Version
|
||||
[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
|
||||
}
|
||||
}
|
||||
|
||||
# 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 "<Version>.*</Version>", "<Version>$newVersion</Version>"
|
||||
Set-Content $ProjectFile -Value $content
|
||||
|
||||
# Update setup script version
|
||||
# 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 "' + $newVersion + '"'
|
||||
$issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"'
|
||||
$versionFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
Set-Content $SetupScript -Value $issContent
|
||||
if ($versionFound) {
|
||||
Set-Content $SetupScript -Value $issContent
|
||||
}
|
||||
}
|
||||
|
||||
# Build project
|
||||
dotnet publish $ProjectFile -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true
|
||||
Write-Host "Building TodoList.Maui (Release)..." -ForegroundColor Cyan
|
||||
dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Build failed"
|
||||
Write-Error "MAUI build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Package
|
||||
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
|
||||
if (Test-Path $ISCC) {
|
||||
& $ISCC $SetupScript
|
||||
@@ -53,3 +52,11 @@ if (Test-Path $ISCC) {
|
||||
} else {
|
||||
Write-Error "Inno Setup compiler not found"
|
||||
}
|
||||
|
||||
$versionParts = $currentVersion.Split(".")
|
||||
$patch = [int]$versionParts[2] + 1
|
||||
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
|
||||
|
||||
$content = Get-Content $ProjectFile -Raw
|
||||
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
|
||||
Set-Content $ProjectFile -Value $content
|
||||
@@ -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
@@ -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>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
@TodoList.Api_HostAddress = http://localhost:5057
|
||||
|
||||
GET {{TodoList.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
Binary file not shown.
+6
-19
@@ -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,16 @@
|
||||
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);
|
||||
}
|
||||
+4
-4
@@ -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()
|
||||
+2
-2
@@ -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
|
||||
+7
-7
@@ -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");
|
||||
});
|
||||
+2
-2
@@ -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
|
||||
+7
-7
@@ -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,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
|
||||
<Compile Remove="DynamicApi\\**\\*.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TodoList.Core\TodoList.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
+3
-3
@@ -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>
|
||||
+194
-38
@@ -1,21 +1,31 @@
|
||||
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 IServiceProvider _serviceProvider;
|
||||
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)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
InitializeComponent();
|
||||
|
||||
_settingsService = serviceProvider.GetRequiredService<IHotKeySettingsService>();
|
||||
@@ -23,30 +33,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(_serviceProvider.GetRequiredService<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 +114,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::Microsoft.Maui.Controls.Application.Current != null &&
|
||||
!global::Microsoft.Maui.Controls.Application.Current.Windows.Contains(_mainWindow))
|
||||
{
|
||||
global::Microsoft.Maui.Controls.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 +169,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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,5 @@
|
||||
|
||||
<ShellContent
|
||||
Title="Home"
|
||||
ContentTemplate="{DataTemplate views:MainPage}"
|
||||
Route="MainPage" />
|
||||
|
||||
</Shell>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,127 @@ 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.AddTransient<Views.MainPage>();
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
#if DEBUG
|
||||
builder.Logging.AddDebug();
|
||||
#if WINDOWS
|
||||
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
|
||||
#elif ANDROID
|
||||
builder.Services.AddSingleton<IEmbeddedWebServerService, MobileEmbeddedWebServerService>();
|
||||
#else
|
||||
builder.Services.AddSingleton<IEmbeddedWebServerService, NoopEmbeddedWebServerService>();
|
||||
#endif
|
||||
|
||||
return builder.Build();
|
||||
#if DEBUG
|
||||
builder.Logging.AddDebug();
|
||||
#endif
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
_ = Task.Run(() => InitializeDatabase(app.Services, connectionString));
|
||||
_ = Task.Run(() => StartWebServer(app.Services));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static AppSettings LoadAppSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = FileSystem.OpenAppPackageFileAsync("appsettings.json").GetAwaiter().GetResult();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
|
||||
}
|
||||
catch
|
||||
{
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AppSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeDatabase(IServiceProvider services, string connectionString)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
|
||||
try
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
|
||||
|
||||
var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString);
|
||||
var actualDbPath = sqliteBuilder.DataSource;
|
||||
if (!string.IsNullOrEmpty(actualDbPath))
|
||||
{
|
||||
var dbDir = Path.GetDirectoryName(actualDbPath);
|
||||
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
|
||||
{
|
||||
Directory.CreateDirectory(dbDir);
|
||||
}
|
||||
}
|
||||
|
||||
dbContext.Database.Migrate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Database initialization failed: {ex.Message}");
|
||||
|
||||
try
|
||||
{
|
||||
var context = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
|
||||
context.Database.EnsureCreated();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartWebServer(IServiceProvider services)
|
||||
{
|
||||
try
|
||||
{
|
||||
var webServer = services.GetRequiredService<IEmbeddedWebServerService>();
|
||||
_ = webServer.StartAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Web server start failed: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Android.Content.Res;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace TodoList.Maui.Platforms.Android;
|
||||
|
||||
public sealed class AndroidAssetFileProvider : IFileProvider
|
||||
{
|
||||
private readonly AssetManager _assets;
|
||||
private readonly string _root;
|
||||
|
||||
public AndroidAssetFileProvider(AssetManager assets, string root)
|
||||
{
|
||||
_assets = assets;
|
||||
_root = NormalizePath(root).TrimEnd('/');
|
||||
}
|
||||
|
||||
public IFileInfo GetFileInfo(string subpath)
|
||||
{
|
||||
var assetPath = Combine(_root, subpath);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
return new NotFoundFileInfo(subpath);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = _assets.Open(assetPath);
|
||||
return new AndroidAssetFileInfo(_assets, assetPath, Path.GetFileName(assetPath), false);
|
||||
}
|
||||
catch (global::Java.IO.FileNotFoundException)
|
||||
{
|
||||
return new NotFoundFileInfo(subpath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new NotFoundFileInfo(subpath);
|
||||
}
|
||||
}
|
||||
|
||||
public IDirectoryContents GetDirectoryContents(string subpath)
|
||||
{
|
||||
var dirPath = Combine(_root, subpath).TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(dirPath))
|
||||
{
|
||||
return NotFoundDirectoryContents.Singleton;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var entries = _assets.List(dirPath) ?? Array.Empty<string>();
|
||||
return new AndroidAssetDirectoryContents(_assets, dirPath, entries);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return NotFoundDirectoryContents.Singleton;
|
||||
}
|
||||
}
|
||||
|
||||
public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
|
||||
|
||||
private static string Combine(string root, string subpath)
|
||||
{
|
||||
var cleanSub = NormalizePath(subpath).TrimStart('/');
|
||||
if (string.IsNullOrEmpty(root)) return cleanSub;
|
||||
if (string.IsNullOrEmpty(cleanSub)) return root;
|
||||
return $"{root}/{cleanSub}";
|
||||
}
|
||||
|
||||
private static string NormalizePath(string path) => (path ?? string.Empty).Replace('\\', '/');
|
||||
|
||||
private sealed class AndroidAssetFileInfo : IFileInfo
|
||||
{
|
||||
private readonly AssetManager _assets;
|
||||
private readonly string _assetPath;
|
||||
|
||||
public AndroidAssetFileInfo(AssetManager assets, string assetPath, string name, bool isDirectory)
|
||||
{
|
||||
_assets = assets;
|
||||
_assetPath = assetPath;
|
||||
Name = name;
|
||||
IsDirectory = isDirectory;
|
||||
}
|
||||
|
||||
public bool Exists => true;
|
||||
public long Length => -1;
|
||||
public string? PhysicalPath => null;
|
||||
public string Name { get; }
|
||||
public DateTimeOffset LastModified => DateTimeOffset.MinValue;
|
||||
public bool IsDirectory { get; }
|
||||
|
||||
public Stream CreateReadStream() => _assets.Open(_assetPath);
|
||||
}
|
||||
|
||||
private sealed class AndroidAssetDirectoryContents : IDirectoryContents
|
||||
{
|
||||
private readonly AssetManager _assets;
|
||||
private readonly string _dirPath;
|
||||
private readonly string[] _entries;
|
||||
|
||||
public AndroidAssetDirectoryContents(AssetManager assets, string dirPath, string[] entries)
|
||||
{
|
||||
_assets = assets;
|
||||
_dirPath = dirPath;
|
||||
_entries = entries;
|
||||
}
|
||||
|
||||
public bool Exists => true;
|
||||
|
||||
public IEnumerator<IFileInfo> GetEnumerator()
|
||||
{
|
||||
foreach (var entry in _entries)
|
||||
{
|
||||
if (string.IsNullOrEmpty(entry)) continue;
|
||||
|
||||
var childPath = $"{_dirPath}/{entry}";
|
||||
var childList = Array.Empty<string>();
|
||||
var isDir = false;
|
||||
|
||||
try
|
||||
{
|
||||
childList = _assets.List(childPath) ?? Array.Empty<string>();
|
||||
isDir = childList.Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
yield return new AndroidAssetFileInfo(_assets, childPath, entry, isDir);
|
||||
}
|
||||
}
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
|
||||
<application android:allowBackup="true" android:icon="@drawable/appicon" android:roundIcon="@drawable/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Android.App;
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
|
||||
@@ -7,4 +7,12 @@ namespace TodoList.Maui;
|
||||
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
|
||||
#if DEBUG
|
||||
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TodoList.Application.Interfaces;
|
||||
using TodoList.Application.Models;
|
||||
using TodoList.Maui.Models;
|
||||
|
||||
namespace TodoList.Maui.Services;
|
||||
|
||||
public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
{
|
||||
private readonly AppSettings _appSettings;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
private TcpListener? _listener;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _acceptLoop;
|
||||
|
||||
public bool IsRunning => _listener != null;
|
||||
public string BaseUrl => $"http://localhost:{_appSettings.WebServer.Port}";
|
||||
|
||||
public MobileEmbeddedWebServerService(AppSettings appSettings, IServiceProvider services)
|
||||
{
|
||||
_appSettings = appSettings;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
if (_listener != null) return Task.CompletedTask;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new TcpListener(IPAddress.Loopback, _appSettings.WebServer.Port);
|
||||
_listener.Start();
|
||||
|
||||
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_listener == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
_cts?.Cancel();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_listener.Stop();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_acceptLoop != null) await _acceptLoop;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_listener = null;
|
||||
_acceptLoop = null;
|
||||
_cts?.Dispose();
|
||||
_cts = null;
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(CancellationToken token)
|
||||
{
|
||||
while (!token.IsCancellationRequested && _listener != null)
|
||||
{
|
||||
TcpClient? client = null;
|
||||
try
|
||||
{
|
||||
client = await _listener.AcceptTcpClientAsync(token);
|
||||
_ = Task.Run(() => HandleClientAsync(client, token), token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
client?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleClientAsync(TcpClient client, CancellationToken token)
|
||||
{
|
||||
using var _ = client;
|
||||
using var stream = client.GetStream();
|
||||
|
||||
HttpRequestData request;
|
||||
try
|
||||
{
|
||||
request = await ReadRequestAsync(stream, token);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.Path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleApiAsync(stream, request, token);
|
||||
return;
|
||||
}
|
||||
|
||||
await HandleStaticAsync(stream, request, token);
|
||||
}
|
||||
|
||||
private async Task HandleApiAsync(Stream stream, HttpRequestData request, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = request.Path;
|
||||
if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
|
||||
return;
|
||||
}
|
||||
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length < 2)
|
||||
{
|
||||
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
|
||||
return;
|
||||
}
|
||||
|
||||
var serviceName = segments[1];
|
||||
if (!serviceName.Equals("task", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
|
||||
return;
|
||||
}
|
||||
|
||||
using var scope = _services.CreateScope();
|
||||
var taskService = scope.ServiceProvider.GetRequiredService<ITaskService>();
|
||||
|
||||
object? result = null;
|
||||
var methodName = string.Empty;
|
||||
|
||||
var tail = segments.Skip(2).ToArray();
|
||||
|
||||
if (request.Method == "GET" && tail.Length == 0)
|
||||
{
|
||||
methodName = nameof(ITaskService.GetAllTasksAsync);
|
||||
result = await taskService.GetAllTasksAsync();
|
||||
}
|
||||
else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("active", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
methodName = nameof(ITaskService.GetActiveTasksAsync);
|
||||
result = await taskService.GetActiveTasksAsync();
|
||||
}
|
||||
else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
methodName = nameof(ITaskService.GetCompletedTasksAsync);
|
||||
result = await taskService.GetCompletedTasksAsync();
|
||||
}
|
||||
else if (request.Method == "GET" && tail.Length == 1 && int.TryParse(tail[0], out var getId))
|
||||
{
|
||||
methodName = nameof(ITaskService.GetTaskByIdAsync);
|
||||
result = await taskService.GetTaskByIdAsync(getId);
|
||||
}
|
||||
else if (request.Method == "GET" && tail.Length == 2 && tail[1].Equals("subtasks", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var parentId))
|
||||
{
|
||||
methodName = nameof(ITaskService.GetSubTasksAsync);
|
||||
result = await taskService.GetSubTasksAsync(parentId);
|
||||
}
|
||||
else if (request.Method == "POST" && tail.Length == 0)
|
||||
{
|
||||
methodName = nameof(ITaskService.CreateTaskAsync);
|
||||
var dto = DeserializeBody<CreateTaskDto>(request.Body);
|
||||
result = await taskService.CreateTaskAsync(dto);
|
||||
}
|
||||
else if (request.Method == "PUT" && tail.Length == 0)
|
||||
{
|
||||
methodName = nameof(ITaskService.UpdateTaskAsync);
|
||||
var dto = DeserializeBody<UpdateTaskDto>(request.Body);
|
||||
result = await taskService.UpdateTaskAsync(dto);
|
||||
}
|
||||
else if (request.Method == "PATCH" && tail.Length == 2 && tail[1].Equals("toggle", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var toggleId))
|
||||
{
|
||||
methodName = nameof(ITaskService.ToggleCompleteAsync);
|
||||
result = await taskService.ToggleCompleteAsync(toggleId);
|
||||
}
|
||||
else if (request.Method == "DELETE" && tail.Length == 1 && int.TryParse(tail[0], out var deleteId))
|
||||
{
|
||||
methodName = nameof(ITaskService.DeleteTaskAsync);
|
||||
await taskService.DeleteTaskAsync(deleteId);
|
||||
result = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = CreateApiResponse(result, null, methodName);
|
||||
await WriteJsonAsync(stream, response.StatusCode, response.Payload, token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var response = CreateApiResponse(null, ex, string.Empty);
|
||||
await WriteJsonAsync(stream, response.StatusCode, response.Payload, token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStaticAsync(Stream stream, HttpRequestData request, CancellationToken token)
|
||||
{
|
||||
var path = request.Path;
|
||||
if (string.IsNullOrEmpty(path) || path == "/") path = "/index.html";
|
||||
|
||||
if (path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
|
||||
return;
|
||||
}
|
||||
|
||||
var assetPath = $"wwwroot{path}";
|
||||
if (path.Contains("..", StringComparison.Ordinal))
|
||||
{
|
||||
await WriteTextAsync(stream, 400, "Bad Request", "text/plain; charset=utf-8", token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryOpenAsset(assetPath, out var assetStream))
|
||||
{
|
||||
if (!Path.HasExtension(path))
|
||||
{
|
||||
assetPath = "wwwroot/index.html";
|
||||
if (TryOpenAsset(assetPath, out assetStream))
|
||||
{
|
||||
await using (assetStream)
|
||||
{
|
||||
await WriteStreamAsync(stream, 200, assetStream, "text/html; charset=utf-8", token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
|
||||
return;
|
||||
}
|
||||
|
||||
await using (assetStream)
|
||||
{
|
||||
await WriteStreamAsync(stream, 200, assetStream, GetContentType(path), token);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryOpenAsset(string assetPath, out Stream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
stream = global::Android.App.Application.Context.Assets.Open(assetPath);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
stream = Stream.Null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetContentType(string path)
|
||||
{
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".html" => "text/html; charset=utf-8",
|
||||
".js" => "text/javascript; charset=utf-8",
|
||||
".css" => "text/css; charset=utf-8",
|
||||
".svg" => "image/svg+xml",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".ico" => "image/x-icon",
|
||||
".json" => "application/json; charset=utf-8",
|
||||
".map" => "application/json; charset=utf-8",
|
||||
".txt" => "text/plain; charset=utf-8",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private static (int StatusCode, object Payload) CreateApiResponse(object? result, Exception? exception, string methodName)
|
||||
{
|
||||
var successMessage = 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 failureMessage = 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) => "操作失败",
|
||||
_ => "操作失败"
|
||||
};
|
||||
|
||||
List<string>? errors = null;
|
||||
if (exception != null)
|
||||
{
|
||||
errors = new List<string> { exception.Message };
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
Success = exception == null,
|
||||
Data = exception == null ? result : null,
|
||||
Message = exception == null ? successMessage : failureMessage,
|
||||
Errors = errors
|
||||
};
|
||||
|
||||
var statusCode = exception switch
|
||||
{
|
||||
KeyNotFoundException => 404,
|
||||
ArgumentException => 400,
|
||||
_ => 200
|
||||
};
|
||||
|
||||
return (statusCode, payload);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private static T DeserializeBody<T>(string body) where T : new()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return new T();
|
||||
return JsonSerializer.Deserialize<T>(body, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
}) ?? new T();
|
||||
}
|
||||
|
||||
private static async Task WriteJsonAsync(Stream stream, int statusCode, object payload, CancellationToken token)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
await WriteTextAsync(stream, statusCode, json, "application/json; charset=utf-8", token);
|
||||
}
|
||||
|
||||
private static async Task WriteTextAsync(Stream stream, int statusCode, string body, string contentType, CancellationToken token)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(body);
|
||||
var header =
|
||||
$"HTTP/1.1 {statusCode} {GetReasonPhrase(statusCode)}\r\n" +
|
||||
$"Content-Type: {contentType}\r\n" +
|
||||
$"Content-Length: {bytes.Length}\r\n" +
|
||||
$"Connection: close\r\n" +
|
||||
$"\r\n";
|
||||
|
||||
var headerBytes = Encoding.ASCII.GetBytes(header);
|
||||
await stream.WriteAsync(headerBytes, token);
|
||||
await stream.WriteAsync(bytes, token);
|
||||
}
|
||||
|
||||
private static async Task WriteStreamAsync(Stream stream, int statusCode, Stream bodyStream, string contentType, CancellationToken token)
|
||||
{
|
||||
await using var ms = new MemoryStream();
|
||||
await bodyStream.CopyToAsync(ms, token);
|
||||
var bodyBytes = ms.ToArray();
|
||||
|
||||
var header =
|
||||
$"HTTP/1.1 {statusCode} {GetReasonPhrase(statusCode)}\r\n" +
|
||||
$"Content-Type: {contentType}\r\n" +
|
||||
$"Content-Length: {bodyBytes.Length}\r\n" +
|
||||
$"Connection: close\r\n" +
|
||||
$"\r\n";
|
||||
|
||||
var headerBytes = Encoding.ASCII.GetBytes(header);
|
||||
await stream.WriteAsync(headerBytes, token);
|
||||
await stream.WriteAsync(bodyBytes, token);
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestData> ReadRequestAsync(NetworkStream stream, CancellationToken token)
|
||||
{
|
||||
using var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, bufferSize: 8192, leaveOpen: true);
|
||||
var requestLine = await reader.ReadLineAsync(token);
|
||||
if (string.IsNullOrWhiteSpace(requestLine)) throw new InvalidOperationException("empty request line");
|
||||
|
||||
var parts = requestLine.Split(' ');
|
||||
if (parts.Length < 2) throw new InvalidOperationException("invalid request line");
|
||||
|
||||
var method = parts[0].Trim().ToUpperInvariant();
|
||||
var target = parts[1].Trim();
|
||||
|
||||
string path;
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri) &&
|
||||
(absoluteUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
|
||||
absoluteUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
path = absoluteUri.AbsolutePath;
|
||||
}
|
||||
else if (target.StartsWith("//", StringComparison.Ordinal) &&
|
||||
Uri.TryCreate("http:" + target, UriKind.Absolute, out var authorityUri))
|
||||
{
|
||||
path = authorityUri.AbsolutePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
path = target;
|
||||
var queryIndex = target.IndexOf('?', StringComparison.Ordinal);
|
||||
if (queryIndex >= 0) path = target.Substring(0, queryIndex);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(path)) path = "/";
|
||||
if (!path.StartsWith("/", StringComparison.Ordinal)) path = "/" + path;
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? line;
|
||||
while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(token)))
|
||||
{
|
||||
var idx = line.IndexOf(':');
|
||||
if (idx <= 0) continue;
|
||||
var name = line.Substring(0, idx).Trim();
|
||||
var value = line.Substring(idx + 1).Trim();
|
||||
headers[name] = value;
|
||||
}
|
||||
|
||||
var body = string.Empty;
|
||||
if (headers.TryGetValue("Content-Length", out var contentLengthStr) && int.TryParse(contentLengthStr, out var contentLength) && contentLength > 0)
|
||||
{
|
||||
var buffer = new char[contentLength];
|
||||
var read = 0;
|
||||
while (read < contentLength)
|
||||
{
|
||||
var n = await reader.ReadAsync(buffer, read, contentLength - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
body = new string(buffer, 0, read);
|
||||
}
|
||||
|
||||
return new HttpRequestData(method, path, body);
|
||||
}
|
||||
|
||||
private static string GetReasonPhrase(int statusCode) => statusCode switch
|
||||
{
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
500 => "Internal Server Error",
|
||||
_ => "OK"
|
||||
};
|
||||
|
||||
private readonly record struct HttpRequestData(string Method, string Path, string Body);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#512BD4"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M24,56 L40,72 L84,28 L92,36 L40,88 L16,64 z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#512BD4"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M24,56 L40,72 L84,28 L92,36 L40,88 L16,64 z" />
|
||||
</vector>
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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,137 @@
|
||||
#if WINDOWS
|
||||
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.UseAuthorization();
|
||||
app.UseDynamicApi();
|
||||
app.MapControllers();
|
||||
|
||||
_webApp = app;
|
||||
|
||||
await _webApp.StartAsync();
|
||||
}
|
||||
|
||||
private void ServeStaticFiles(WebApplication app)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wwwrootPath = Path.Combine(AppContext.BaseDirectory, "wwwroot");
|
||||
if (!Directory.Exists(wwwrootPath))
|
||||
{
|
||||
Console.WriteLine("[EmbeddedWebServer] wwwroot directory not found. Static file serving disabled.");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace TodoList.Maui.Services;
|
||||
|
||||
public sealed class NoopEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
{
|
||||
public bool IsRunning => false;
|
||||
public string BaseUrl => string.Empty;
|
||||
|
||||
public Task StartAsync() => Task.CompletedTask;
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -17,21 +17,27 @@
|
||||
<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 +47,43 @@
|
||||
<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>
|
||||
<ForceWebBuild>false</ForceWebBuild>
|
||||
</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>
|
||||
<EnableMauiImageProcessing>false</EnableMauiImageProcessing>
|
||||
<DisableResizetizer>true</DisableResizetizer>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
|
||||
<AndroidUseSharedRuntime>True</AndroidUseSharedRuntime>
|
||||
<AndroidEnableFastDeployment>True</AndroidEnableFastDeployment>
|
||||
<AndroidFastDeploymentType>Assemblies</AndroidFastDeploymentType>
|
||||
<EmbedAssembliesIntoApk>False</EmbedAssembliesIntoApk>
|
||||
<AndroidSupportedAbis>x86_64</AndroidSupportedAbis>
|
||||
<UseAppHost>false</UseAppHost>
|
||||
</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 +95,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 -->
|
||||
@@ -59,27 +103,103 @@
|
||||
|
||||
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
<MauiAsset Include="appsettings.json" LogicalName="appsettings.json" />
|
||||
</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.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="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="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
|
||||
<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>
|
||||
|
||||
<Target Name="BuildTodoListWeb"
|
||||
BeforeTargets="UpdateAndroidAssets;BeforeBuild"
|
||||
Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoListWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoListWebDistDir)\index.html'))">
|
||||
<Exec Command="npm ci" WorkingDirectory="$(TodoListWebDir)"
|
||||
Condition="Exists('$(TodoListWebDir)\package-lock.json') And !Exists('$(TodoListWebDir)\node_modules')" />
|
||||
<Exec Command="npm install" WorkingDirectory="$(TodoListWebDir)"
|
||||
Condition="!Exists('$(TodoListWebDir)\package-lock.json') And !Exists('$(TodoListWebDir)\node_modules')" />
|
||||
<Exec Command="npm run build" WorkingDirectory="$(TodoListWebDir)" />
|
||||
</Target>
|
||||
|
||||
<Target Name="SyncTodoListWebDistToMauiWwwroot"
|
||||
BeforeTargets="ProcessMauiAssets"
|
||||
DependsOnTargets="BuildTodoListWeb"
|
||||
Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoListWebDistDir)\index.html')">
|
||||
<ItemGroup>
|
||||
<_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<RemoveDir Directories="$(MSBuildProjectDirectory)\wwwroot" Condition="Exists('$(MSBuildProjectDirectory)\wwwroot')" />
|
||||
<MakeDir Directories="$(MSBuildProjectDirectory)\wwwroot" />
|
||||
|
||||
<Copy SourceFiles="@(_TodoListWebDistFiles)"
|
||||
DestinationFiles="@(_TodoListWebDistFiles->'$(MSBuildProjectDirectory)\wwwroot\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
|
||||
<ItemGroup>
|
||||
<MauiAsset Include="@(_TodoListWebDistFiles)">
|
||||
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
</MauiAsset>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyTodoListWebDistToWindowsWwwroot"
|
||||
BeforeTargets="Build"
|
||||
DependsOnTargets="BuildTodoListWeb"
|
||||
Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And Exists('$(TodoListWebDistDir)')">
|
||||
<ItemGroup>
|
||||
<_TodoListWebDistFiles Include="$(TodoListWebDistDir)\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
|
||||
<MakeDir Directories="$(TargetDir)wwwroot" />
|
||||
|
||||
<Copy SourceFiles="@(_TodoListWebDistFiles)"
|
||||
DestinationFiles="@(_TodoListWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,20 +6,37 @@ namespace TodoList.Maui.Views
|
||||
{
|
||||
public partial class MainPage : ContentPage
|
||||
{
|
||||
private readonly IHotKeySettingsService _settingsService;
|
||||
private readonly AppSettings _appSettings;
|
||||
private readonly IEmbeddedWebServerService? _webServer;
|
||||
#if WINDOWS
|
||||
private Platforms.Windows.WindowsKeyboardHandler? _keyboardHandler;
|
||||
#endif
|
||||
|
||||
public MainPage()
|
||||
public MainPage(AppSettings appSettings, IEmbeddedWebServerService webServer)
|
||||
{
|
||||
InitializeComponent();
|
||||
_settingsService = new HotKeySettingsService();
|
||||
_appSettings = appSettings;
|
||||
_webServer = webServer;
|
||||
|
||||
SetupWebViewSource();
|
||||
SetupWebViewCommunication();
|
||||
SetupKeyboardHandler();
|
||||
}
|
||||
|
||||
public const string Version = "1.0.0";
|
||||
|
||||
private void SetupWebViewSource()
|
||||
{
|
||||
if (_appSettings.WebServer.IsUsingStatic)
|
||||
{
|
||||
if (_webServer != null)
|
||||
{
|
||||
MainWebView.Source = _webServer.BaseUrl;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
MainWebView.Source = NormalizeUrl(_appSettings.WebServer.ForEndUrl);
|
||||
}
|
||||
|
||||
private void SetupKeyboardHandler()
|
||||
{
|
||||
@@ -32,7 +49,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 +63,19 @@ namespace TodoList.Maui.Views
|
||||
{
|
||||
MainWebView.Navigated += async (s, e) =>
|
||||
{
|
||||
#if DEBUG
|
||||
if (e.Result != WebNavigationResult.Success)
|
||||
{
|
||||
await DisplayAlertAsync("加载失败", $"{e.Url}\n{e.Result}", "OK");
|
||||
}
|
||||
#endif
|
||||
|
||||
if (_webServer is { IsRunning: true })
|
||||
{
|
||||
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
|
||||
await MainWebView.EvaluateJavaScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
|
||||
}
|
||||
|
||||
await MainWebView.EvaluateJavaScriptAsync(@"
|
||||
window.mauiInterop = {
|
||||
onHotKeyConfigUpdated: null,
|
||||
@@ -68,34 +98,23 @@ 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)
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
var config = _settingsService.GetConfig();
|
||||
var configJson = System.Text.Json.JsonSerializer.Serialize(config);
|
||||
await MainWebView.EvaluateJavaScriptAsync($"window.mauiInterop.openHotKeySettings({configJson});");
|
||||
if (string.IsNullOrWhiteSpace(url)) return url;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return url;
|
||||
|
||||
var host = uri.Host;
|
||||
if (host != "localhost" && host != "127.0.0.1") return url;
|
||||
|
||||
if (DeviceInfo.Platform == DevicePlatform.Android && DeviceInfo.DeviceType == DeviceType.Virtual)
|
||||
{
|
||||
var builder = new UriBuilder(uri) { Host = "10.0.2.2" };
|
||||
return builder.Uri.ToString();
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"WebServer": {
|
||||
"Port": 5057,
|
||||
"IsUsingStatic": true,
|
||||
"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
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user