Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dbd97103c | |||
| 1f87565d5a | |||
| d94d1f36d2 | |||
| d53828c150 |
@@ -370,3 +370,7 @@ FodyWeavers.xsd
|
||||
/src/Hua.Todo.Host/Hua.Todo.db-wal
|
||||
/.artifacts/buildcheck
|
||||
/.trae
|
||||
/.artifacts
|
||||
/.android-sdk
|
||||
/src/Hua.Todo.Avalonia/wwwroot
|
||||
/src/Hua.Todo.Avalonia/wwwroot
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
|
||||
"-c",
|
||||
"Release",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- 统一版本号 -->
|
||||
<Version>1.2.8</Version>
|
||||
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
|
||||
<!-- 通用元数据 -->
|
||||
<Authors>ShaoHua</Authors>
|
||||
<Company>Hua.Todo</Company>
|
||||
<Product>Hua.Todo</Product>
|
||||
<Copyright>Copyright © 2024 ShaoHua</Copyright>
|
||||
<Description>A simple cross-platform Todo application.</Description>
|
||||
|
||||
<!-- 编译优化选项 -->
|
||||
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
|
||||
<!-- 禁用 MSBuild 节点重用,避免在多项目并发或调试期间出现“文件正由另一进程使用” (XARDF7024) 的问题。 -->
|
||||
<MSBuildDisableNodeReuse>true</MSBuildDisableNodeReuse>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup Condition="'$(TargetFramework)' != ''">
|
||||
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定 Windows RID(win-*)时强制禁用,避免还原阶段解析 Mono.win-x64 runtime pack。 -->
|
||||
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-'))">false</UseMonoRuntime>
|
||||
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True)">true</UseMonoRuntime>
|
||||
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RID(win-*/linux-*/osx-*)时强制禁用,避免还原阶段解析对应的 Mono runtime pack。 -->
|
||||
<UseMonoRuntime Condition="'$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')))">false</UseMonoRuntime>
|
||||
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) and ('$(RuntimeIdentifier)' == '' or ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) != True and $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-')) != True))">true</UseMonoRuntime>
|
||||
<UseMonoRuntime Condition="$([System.String]::Copy($(TargetFramework)).Contains('-android')) != True">false</UseMonoRuntime>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
|
||||
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
|
||||
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
|
||||
<Build Solution="Debug|*" Project="false" />
|
||||
<Deploy Solution="Debug|Any CPU" />
|
||||
</Project>
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
# Hua.Todo 跨平台代办管理应用
|
||||
# Hua.Todo 跨平台代办管理应用 v1.2.8
|
||||
|
||||
一个基于 WebView 容器(MAUI / Avalonia)+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
|
||||
一个基于 WebView 容器(MAUI / Avalonia)+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
|
||||
|
||||
## 🚀 功能特点
|
||||
|
||||
### 核心功能
|
||||
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
|
||||
- **任务管理**:支持创建、编辑、删除、完成状态切换
|
||||
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
|
||||
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
|
||||
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
|
||||
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
|
||||
- **云同步(基础)**:支持手动配置服务端地址并登录后拉取云端任务(v1.2.0 为只读展示)
|
||||
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux。
|
||||
- **任务管理**:支持创建、编辑、删除、完成状态切换、子任务管理。
|
||||
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略(SecurityPolicy)配置。
|
||||
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感。
|
||||
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析。
|
||||
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试。
|
||||
|
||||
## 📦 安装与使用
|
||||
|
||||
@@ -58,8 +57,8 @@ npm run dev
|
||||
|
||||
### Windows 交付产物(安装包)
|
||||
|
||||
- 运行 `publish-windows.ps1` 生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj` 的 `<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`)
|
||||
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows:`publish.ps1 -Windows`)
|
||||
- 运行 `publish-windows.ps1` 默认使用 `Release` 配置生成 Inno Setup 安装包:`src/Hua.Todo.Maui/Output/Hua.Todo_Setup_vX.Y.Z.exe`(版本号来自 `Hua.Todo.Maui.csproj` 的 `<Version>`;根目录 `Directory.Build.targets` 会对 `Hua.Todo.Maui` 按 TargetFramework 条件配置 `UseMonoRuntime`:仅 Android 启用,其它目标关闭;同时会复制到 `artifacts/windows/<RID>/installer/`)
|
||||
- 运行 `publish.ps1` 默认会同时发布 Windows + Linux(仅发布 Windows:`publish.ps1 -Windows`),且均默认使用 `Release` 配置。
|
||||
- 安装后主程序为:`Hua.Todo.Maui.exe`(快捷方式/安装后启动均指向该文件)
|
||||
- 发布产物默认使用静态资源:`src/Hua.Todo.Maui/appsettings.json` 中 `WebServer.IsUsingStatic=true`
|
||||
- 前端构建产物会输出到 `src/Hua.Todo.Maui/wwwroot`,并随 Windows 发布复制到发布目录(嵌入式服务器从 `AppContext.BaseDirectory/wwwroot` 提供静态文件)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Hua.Todo 代码规范文档 v1.1.0
|
||||
# Hua.Todo 代码规范文档 v1.2.8
|
||||
|
||||
## 1. 概述
|
||||
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
|
||||
@@ -10,21 +10,29 @@
|
||||
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
|
||||
- **一致性**: 在整个项目中保持命名风格一致
|
||||
|
||||
### 2.2 注释规范
|
||||
- **公共 API 必须添加 XML 文档注释**
|
||||
### 2.2 注释规范 (强制)
|
||||
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
|
||||
- ** summary**:一句话说明用途
|
||||
- ** param / returns**:关键参数/返回值说明
|
||||
- ** 异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
|
||||
- **复杂逻辑添加行内注释**
|
||||
- **避免注释显而易见的代码**
|
||||
- **保持注释与代码同步更新**
|
||||
- **禁止在日志或注释中输出密钥、Token、用户隐私信息**
|
||||
|
||||
### 2.3 代码格式化
|
||||
- **使用统一的代码格式化工具**
|
||||
- **使用统一的代码格式化工具** (VS / IDE 默认格式化)
|
||||
- **保持一致的缩进和空格**
|
||||
- **每行代码不超过 120 字符**
|
||||
- **文件末尾保留一个空行**
|
||||
|
||||
## 3. C# 代码规范
|
||||
|
||||
### 3.1 命名规范
|
||||
### 3.1 跨平台逻辑规范
|
||||
- **禁止混写 `#if`**:禁止在同一文件内混写多个平台的大段 `#if` 实现。
|
||||
- **优先使用 partial/接口**:应优先使用 `partial` 类、接口与平台目录分离。
|
||||
- **说明平台差异**:平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
|
||||
|
||||
### 3.2 异步/后台任务
|
||||
- **必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入**。
|
||||
|
||||
#### 类和接口
|
||||
```csharp
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端技术栈
|
||||
- **开发语言**:C# 10
|
||||
- **框架**:.NET 10
|
||||
- **开发语言**:C# 13 (基于 .NET 10)
|
||||
- **框架**:.NET 10 (Preview/Early Adopter)
|
||||
- **UI 框架**:MAUI(移动端/部分桌面) + Avalonia(桌面端)
|
||||
- **Web 服务器**:Kestrel (ASP.NET Core 内置)
|
||||
- **API 框架**:ASP.NET Core Web API
|
||||
- **数据访问**:Entity Framework Core
|
||||
- **API 框架**:ASP.NET Core Web API (支持动态 API 生成)
|
||||
- **数据访问**:Entity Framework Core 10.0
|
||||
- **数据库**:SQLite (本地存储)
|
||||
- **依赖注入**:Microsoft.Extensions.DependencyInjection
|
||||
|
||||
### 前端技术栈
|
||||
- **开发语言**:TypeScript
|
||||
- **开发语言**:TypeScript 5+
|
||||
- **框架**:Vue.js 3
|
||||
- **构建工具**:Vite
|
||||
- **构建工具**:Vite 5+
|
||||
- **HTTP 客户端**:Axios
|
||||
- **状态管理**:Pinia
|
||||
- **UI 组件库**:Element Plus / Vant (移动端)
|
||||
@@ -24,13 +24,17 @@
|
||||
## 🎯 核心模块说明
|
||||
|
||||
### Hua.Todo.Core
|
||||
领域实体层,定义核心实体(TaskEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
|
||||
领域实体层,定义核心实体(TaskEntity)、安全策略实体(SecurityPolicyEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
|
||||
|
||||
### Hua.Todo.Application
|
||||
应用层实现,包含业务逻辑、动态 API 生成逻辑、EF Core 数据库上下文以及具体的服务实现(TaskService)。
|
||||
应用层实现,包含:
|
||||
- **业务逻辑**:TaskService 实现。
|
||||
- **动态 API**:基于 Middleware 的动态 API 生成逻辑。
|
||||
- **云同步 (CloudSync)**:包含 Auth 验证、同步服务、安全策略管理等。
|
||||
- **数据访问**:EF Core 数据库上下文(TodoDbContext)与迁移。
|
||||
|
||||
### Hua.Todo.Host
|
||||
后端 API 宿主,提供运行环境和配置,是后端服务的启动入口。
|
||||
后端 API 宿主,作为独立 Server 运行时提供运行环境和配置。
|
||||
|
||||
### Hua.Todo.Web
|
||||
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
### 2.1 后端技术栈
|
||||
- **开发语言**: C# 10
|
||||
- **框架**: .NET 10
|
||||
- **UI 框架**: MAUI (Multi-platform App UI)
|
||||
- **UI 框架**: MAUI (Multi-platform App UI) + Avalonia (Linux 支持)
|
||||
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
|
||||
- **API 框架**: ASP.NET Core Web API
|
||||
- **数据访问**: Entity Framework Core
|
||||
@@ -34,7 +34,8 @@ Hua.Todo/
|
||||
│ │ ├── 技术栈与模块.md
|
||||
│ │ ├── 版本记录.md
|
||||
│ │ ├── 技术设计文档.md(本文件)
|
||||
│ │ └── 代码规范文档.md
|
||||
│ │ ├── 代码规范文档.md
|
||||
│ │ └── 部署文档.md
|
||||
│ └── project/ # 项目进度/需求文档
|
||||
│ ├── 产品需求文档.md
|
||||
│ └── ...
|
||||
@@ -73,7 +74,30 @@ Hua.Todo/
|
||||
│ │ ├── MauiProgram.cs # MAUI 程序配置
|
||||
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
|
||||
│ │
|
||||
│ ├── Hua.Todo.Api/ # 后端 API 项目
|
||||
│ ├── Hua.Todo.Avalonia/ # Avalonia 项目(Linux 支持)
|
||||
│ │ ├── Assets/ # 资源文件
|
||||
│ │ │ └── icon.ico # 应用图标
|
||||
│ │ ├── Services/ # 服务层
|
||||
│ │ │ ├── EmbeddedWebServerServiceFactory.cs
|
||||
│ │ │ ├── GlobalHotKeyServiceFactory.cs
|
||||
│ │ │ ├── NoopEmbeddedWebServerService.cs
|
||||
│ │ │ └── Platforms/ # 平台特定服务
|
||||
│ │ │ ├── AndroidGlobalHotKeyService.cs
|
||||
│ │ │ └── LinuxGlobalHotKeyService.cs
|
||||
│ │ ├── Views/ # 视图
|
||||
│ │ │ ├── MainView.axaml
|
||||
│ │ │ ├── MainView.axaml.cs
|
||||
│ │ │ ├── MainWindow.axaml
|
||||
│ │ │ └── MainWindow.axaml.cs
|
||||
│ │ ├── App.axaml # Avalonia 应用入口
|
||||
│ │ ├── App.axaml.cs
|
||||
│ │ ├── Program.cs # Avalonia 程序配置
|
||||
│ │ ├── appsettings.json # 配置文件
|
||||
│ │ ├── setup.iss # 安装脚本
|
||||
│ │ ├── wwwroot/ # 前端静态资源
|
||||
│ │ └── Hua.Todo.Avalonia.csproj # Avalonia 项目文件
|
||||
│ │
|
||||
│ ├── Hua.Todo.Host/ # 后端 API 项目
|
||||
│ │ ├── Controllers/ # API 控制器
|
||||
│ │ │ ├── TasksController.cs
|
||||
│ │ │ ├── SettingsController.cs
|
||||
@@ -89,6 +113,8 @@ Hua.Todo/
|
||||
│ │ │ └── SyncService.cs
|
||||
│ │ ├── Data/ # 数据访问层
|
||||
│ │ │ ├── TodoDbContext.cs
|
||||
│ │ │ ├── Converters/ # 类型转换器
|
||||
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
|
||||
│ │ │ ├── Repositories/
|
||||
│ │ │ │ ├── ITaskRepository.cs
|
||||
│ │ │ │ └── TaskRepository.cs
|
||||
@@ -99,7 +125,14 @@ Hua.Todo/
|
||||
│ │ │ └── ServiceCollectionExtensions.cs
|
||||
│ │ ├── Program.cs # API 入口
|
||||
│ │ ├── appsettings.json # 配置文件
|
||||
│ │ └── Hua.Todo.Api.csproj # API 项目文件
|
||||
│ │ └── Hua.Todo.Host.csproj # Host 项目文件
|
||||
│ │
|
||||
│ ├── Hua.Todo.Application/ # 应用层
|
||||
│ │ ├── Data/ # 数据访问
|
||||
│ │ │ ├── TodoDbContext.cs
|
||||
│ │ │ └── Converters/ # 类型转换器
|
||||
│ │ │ └── LenientUtcDateTimeStringConverter.cs
|
||||
│ │ └── Hua.Todo.Application.csproj # Application 项目文件
|
||||
│ │
|
||||
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
|
||||
│ │ ├── Entities/ # 实体类
|
||||
@@ -116,7 +149,7 @@ Hua.Todo/
|
||||
│ │
|
||||
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
|
||||
│ │ ├── public/ # 静态资源
|
||||
│ │ │ └── index.html
|
||||
│ │ │ └── favicon.svg # 网站图标
|
||||
│ │ ├── src/ # 源代码
|
||||
│ │ │ ├── api/ # API 调用
|
||||
│ │ │ │ ├── client.ts # HTTP 客户端配置
|
||||
@@ -182,7 +215,23 @@ Hua.Todo/
|
||||
- `WebViewContainer`: 封装 WebView 控件
|
||||
- 平台特定服务: 快捷键、通知等
|
||||
|
||||
### 4.2 后端 API 项目 (Hua.Todo.Host)
|
||||
### 4.2 Avalonia 项目 (Hua.Todo.Avalonia)
|
||||
**职责**:
|
||||
- Linux 平台支持
|
||||
- 桌面交互功能(托盘菜单、全局热键等)
|
||||
- WebView 容器管理
|
||||
- 本地 HTTP 服务器启动
|
||||
|
||||
**关键组件**:
|
||||
- `Program.cs`: 配置 Avalonia 应用和依赖注入
|
||||
- `App.axaml.cs`: 应用程序主入口
|
||||
- `MainWindow.axaml.cs`: 主窗口管理
|
||||
- `MainView.axaml.cs`: 主视图管理
|
||||
- `GlobalHotKeyServiceFactory`: 全局热键服务工厂
|
||||
- `EmbeddedWebServerServiceFactory`: 内嵌 Web 服务器服务工厂
|
||||
- 平台特定服务: Linux 全局热键等
|
||||
|
||||
### 4.3 后端 API 项目 (Hua.Todo.Host)
|
||||
**职责**:
|
||||
- 提供 RESTful API 接口
|
||||
- 业务逻辑处理
|
||||
@@ -198,7 +247,7 @@ Hua.Todo/
|
||||
- `Data`: 数据访问层和数据库上下文
|
||||
- `Program.cs`: API 服务器配置和启动
|
||||
|
||||
### 4.3 核心业务层 (Hua.Todo.Core)
|
||||
### 4.4 核心业务层 (Hua.Todo.Core)
|
||||
**职责**:
|
||||
- 定义领域模型和业务规则
|
||||
- 提供核心业务接口
|
||||
@@ -210,7 +259,7 @@ Hua.Todo/
|
||||
- `ValueObjects`: 值对象
|
||||
- `Specifications`: 业务规范
|
||||
|
||||
### 4.4 前端 Web 项目 (Hua.Todo.Web)
|
||||
### 4.5 前端 Web 项目 (Hua.Todo.Web)
|
||||
**职责**:
|
||||
- 用户界面展示
|
||||
- 用户交互处理
|
||||
|
||||
@@ -9,12 +9,25 @@
|
||||
- v1.1.0:MAUI + WebView 跨平台版本
|
||||
- v1.2.0 (规划中):Linux 支持与增强功能
|
||||
|
||||
### v1.2.0(开发中,2026-04-07)
|
||||
### v1.2.8 (2026-04-13)
|
||||
|
||||
- **云同步增强**:在 `Hua.Todo.Application` 中深度集成 `CloudSync` 模块,支持权限验证、安全策略(SecurityPolicy)与任务同步 DTO。
|
||||
- **动态 API 增强**:完善 `DynamicApi` 逻辑,支持 Swagger 自动过滤与中间件拦截。
|
||||
- **代码规范同步**:强制执行 XML 文档注释与跨平台逻辑分离规范,更新 `.trae/rules` 规则库。
|
||||
- **多平台构建优化**:优化 `Directory.Build.props` 与 `Directory.Build.targets`,精细化控制各平台(Windows/Android/iOS/Linux)的构建开关与依赖。
|
||||
- **版本号统一**:全项目版本号提升至 `v1.2.8`,同步更新各平台安装包与发布脚本。
|
||||
|
||||
### v1.2.0(2026-04-07)
|
||||
|
||||
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
|
||||
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
|
||||
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
|
||||
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
|
||||
- **MAUI(Windows)内嵌 API 文档**:Debug 模式下,内嵌 WebServer 默认提供 Swagger UI(`{HostUrl}/swagger`)与 OpenAPI JSON(`{HostUrl}/swagger/v1/swagger.json`),便于本地接口调试。
|
||||
- **Android 启动稳定性修复**:在 AndroidManifest 中移除 `androidx.startup.InitializationProvider` 自动初始化入口,规避 `androidx.lifecycle.ProcessLifecycleInitializer` 缺失导致的启动崩溃(`NoClassDefFoundError`)。
|
||||
- **MAUI Android 调试配置修复**:在 `Hua.Todo.Maui.csproj` 中显式启用 `AndroidApplication`,并将调试架构配置从 `AndroidSupportedAbis` 切换为 `RuntimeIdentifiers=android-x64`,减少 Visual Studio 启动 Android 调试时的项目识别与模拟器架构问题。
|
||||
- **Swagger 输出补齐 Dynamic API**:任务管理等 Dynamic API 端点会出现在 `swagger.json` 中,避免“接口缺失”导致联调困难。
|
||||
- **SQLite DateTime 兼容修复**:新增 `LenientUtcDateTimeStringConverter`,本地数据库中若存在历史遗留的 DateTime “ticks/时间戳字符串”脏数据,读取时将被兼容解析,避免 `/api/task` 等查询因单条坏数据整体失败。
|
||||
- **SPA 路由回落行为修复**:当 Release/非 Debug 未启用 Swagger 时,`/swagger` 不再被当作“后端专用路径”排除,访问会按 SPA 路由规则回落到 `/index.html`,避免直接 404。
|
||||
- **MAUI 多平台构建开关**:在 Windows 开发机上默认仅构建 Android + Windows 目标,避免 iOS/MacCatalyst 目标在非 macOS 环境触发运行时包缺失(NETSDK1082);在 macOS 上仍会包含 iOS/MacCatalyst 目标。
|
||||
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 `publish.ps1` 作为统一入口(默认发布 Windows + Linux),Windows 发布脚本支持开关打包与版本自增,发布产物会落盘到 `artifacts/`。
|
||||
@@ -22,9 +35,10 @@
|
||||
- **Windows WebView2 数据目录调整**:MAUI(Unpackaged)默认会在安装目录生成 `Hua.Todo.Maui.exe.WebView2`;现改为写入 `%LocalAppData%\Hua.Todo\WebView2`,避免污染安装目录。
|
||||
- **Windows WebView2 Runtime 误判修复**:当系统已安装 WebView2 Runtime 但发布产物缺少/裁剪 WebView2 托管程序集时,旧检测逻辑会误判为“未安装”;现改为优先从常见安装目录探测 Evergreen 版本,避免阻断主界面加载。
|
||||
- **Windows 三件套开发体验**:新增 `start-host.ps1` / `start-dev.ps1`,并在 MAUI 中约定 `IsUsingStatic=false` 时不启动内置 WebServer,避免注入覆盖 Vite 的 `/api -> 5173` 代理配置。
|
||||
- **Avalonia 桌面交互对齐 MAUI**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
|
||||
|
||||
### v1.1.1 (2026-04-06)
|
||||
- **文档与部署指南**:新增 `docs/manual/部署文档.md`,详细说明开发环境搭建、多平台发布流程(Windows/Linux/Docker)以及关键配置项;并在技术设计文档中建立链接。
|
||||
- **用户文档完善**:在规划中新增了 `docs/manual/新手指南.md` 和 `docs/manual/用户指南.md`。
|
||||
27→
|
||||
28→### v1.1.1 (2026-04-06)
|
||||
|
||||
- **文档规范增强**:新增文档同步规则,强制代码变更与文档更新保持同步。
|
||||
- **项目结构说明校准**:修正 README.md 和技术文档中对 `Hua.Todo.Host`、`Hua.Todo.Application` 等模块的路径与职责描述。
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Hua.Todo 构建与部署手册
|
||||
|
||||
本文档提供 Hua.Todo 项目的版本构建指南与多平台部署流程,侧重于如何产出可分发的版本并将其安装或部署到目标机器。
|
||||
|
||||
## 1. 🏗️ 版本构建流程 (Release Build)
|
||||
|
||||
在进行任何部署之前,必须先通过自动化脚本构建出 Release 版本的产物。
|
||||
|
||||
### 1.1 前提要求
|
||||
|
||||
- **环境检查**:已安装 .NET 10 SDK、Node.js 18+ 以及 Inno Setup 6(仅 Windows 打包需要)。
|
||||
- **脚本入口**:位于根目录的 `publish.ps1` 系列脚本。
|
||||
|
||||
### 1.2 执行构建
|
||||
|
||||
根据目标平台执行对应的构建命令:
|
||||
|
||||
- **全平台一键构建**:
|
||||
```powershell
|
||||
.\publish.ps1 -Windows -Linux
|
||||
```
|
||||
- **Windows 版本构建**:
|
||||
```powershell
|
||||
.\publish-windows.ps1
|
||||
```
|
||||
- **Linux 版本构建**:
|
||||
```powershell
|
||||
.\publish-linux.ps1 -RuntimeIdentifier linux-x64 -SelfContained
|
||||
```
|
||||
|
||||
### 1.3 产物输出位置
|
||||
|
||||
所有构建产物将按平台归类在根目录的 `artifacts/` 文件夹下:
|
||||
|
||||
- **Windows**: `artifacts\windows\win-x64\installer\` (安装包) 与 `publish\` (解压即用版)
|
||||
- **Linux**: `artifacts\linux\linux-x64\` (`.tar.gz` 压缩包)
|
||||
|
||||
***
|
||||
|
||||
## 2. 🪟 Windows 客户端部署
|
||||
|
||||
### 2.1 安装包分发 (Recommended)
|
||||
|
||||
1. **分发文件**:将 `hua.todo-{version}-win-x64-setup.exe` 提供给终端用户。
|
||||
2. **安装流程**:用户运行安装程序,程序将自动:
|
||||
- 安装到 `%ProgramFiles%\Hua.Todo`(或用户自定义路径)。
|
||||
- 创建桌面与开始菜单快捷方式。
|
||||
- 写入注册表以支持卸载。
|
||||
3. **静默安装**:支持 Inno Setup 标准静默参数 `/VERYSILENT /SUPPRESSMSGBOXES`。
|
||||
|
||||
### 2.2 绿色版部署
|
||||
|
||||
1. **分发文件**:将 `artifacts\windows\win-x64\publish\` 文件夹整体打包。
|
||||
2. **运行要求**:目标机器需安装 **WebView2 Runtime**。
|
||||
3. **启动程序**:运行 `Hua.Todo.Maui.exe`。
|
||||
|
||||
***
|
||||
|
||||
## 3. 🐧 Linux 客户端部署
|
||||
|
||||
目前提供基于 Avalonia 的 Linux 交付版本,采用 `.tar.gz` 压缩包形式。
|
||||
|
||||
### 3.1 解压部署
|
||||
|
||||
1. **解压**:
|
||||
```bash
|
||||
tar -xzf hua.todo-{version}-linux-x64.tar.gz -C /opt/hua-todo
|
||||
```
|
||||
2. **运行环境**:
|
||||
- 确保系统安装了 `libwebkit2gtk-4.0-37`。
|
||||
- 如果构建时未开启 `-SelfContained`,则需安装 .NET 10 Runtime。
|
||||
3. **权限设置**:
|
||||
```bash
|
||||
chmod +x /opt/hua-todo/Hua.Todo.Avalonia
|
||||
```
|
||||
4. **启动**:直接运行 `/opt/hua-todo/Hua.Todo.Avalonia`。
|
||||
|
||||
***
|
||||
|
||||
## 4. ☁️ 云同步服务端部署 (Server)
|
||||
|
||||
`Hua.Todo.Host` 可作为中心服务端部署,用于任务的云端同步。
|
||||
|
||||
### 4.1 Docker 部署 (Recommended)
|
||||
|
||||
1. **上传代码**或将 `src/Hua.Todo.Host/Dockerfile` 复制到服务器。
|
||||
2. **构建与启动**:
|
||||
```bash
|
||||
docker build -t hua-todo-server -f src/Hua.Todo.Host/Dockerfile .
|
||||
docker run -d \
|
||||
--name hua-todo-server \
|
||||
-p 5173:5173 \
|
||||
-v /data/hua-todo/db:/app/data \
|
||||
-e ASPNETCORE_ENVIRONMENT=Production \
|
||||
hua-todo-server
|
||||
```
|
||||
|
||||
### 4.2 直接部署 (Binary)
|
||||
|
||||
1. **构建 Host**:`dotnet publish src/Hua.Todo.Host -c Release -o ./dist`。
|
||||
2. **同步产物**:将 `./dist` 内容同步到服务器。
|
||||
3. **配置 Systemd 服务**(可选):
|
||||
创建一个 `hua-todo.service` 文件,指向可执行程序并设置工作目录。
|
||||
|
||||
***
|
||||
|
||||
## 5. ⚙️ 关键配置校准
|
||||
|
||||
### 5.1 数据持久化
|
||||
|
||||
- **SQLite 路径**:客户端数据默认存储在用户目录下的 `Hua.Todo/todo.db`;服务端数据存储在容器挂载卷或指定 `appsettings.json` 的连接字符串中。
|
||||
- **备份建议**:定期备份 `todo.db` 文件。
|
||||
|
||||
### 5.2 端口与访问
|
||||
|
||||
- **客户端本地端口**:默认 `5057`。如果被占用,可在 `appsettings.json` 中修改 `WebServer.HostUrl`。
|
||||
- **服务端访问**:确保服务端防火墙已开放对应的 API 端口(默认 5173)。
|
||||
|
||||
***
|
||||
|
||||
## 6. 🛠️ 常见问题排查
|
||||
|
||||
- **客户端无法加载 UI**:检查目标机器是否安装了 WebView2 Runtime(Windows)或 WebKitGTK(Linux)。
|
||||
- **同步连接失败**:
|
||||
1. 确认服务端 API 是否可达(访问 `/swagger` 验证)。
|
||||
2. 确认客户端配置的服务端地址格式(需包含 `http://` 或 `https://`)。
|
||||
- **权限问题**:在 Linux 下运行前,务必执行 `chmod +x` 给主程序执行权限。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 解决方案:统一版本与打包分发
|
||||
|
||||
## 现状分析
|
||||
- **版本不一致**:`Hua.Todo.Maui` 的版本号为 `1.2.3`,而 `Hua.Todo.Avalonia` 和 `Hua.Todo.Host` 默认使用 `1.0.0`。
|
||||
- **打包脚本缺失**:目前仅 `Hua.Todo.Maui` 拥有 `setup.iss` (Inno Setup) 打包脚本,版本号硬编码为 `1.2.2`。
|
||||
- **配置冗余**:版本信息分散在各个 `.csproj` 和 `.iss` 文件中,维护困难。
|
||||
|
||||
## 目标
|
||||
- 统一所有项目的版本号为 `1.2.3`。
|
||||
- 采用 `Directory.Build.props` 集中管理全局属性。
|
||||
- 为 `Hua.Todo.Avalonia` 提供与 `Hua.Todo.Maui` 一致的 Windows 安装程序打包能力。
|
||||
|
||||
## 实施方案
|
||||
|
||||
### 1. 统一 .NET 项目版本号
|
||||
- 在项目根目录创建 `Directory.Build.props` 文件。
|
||||
- 定义全局版本号 `<Version>1.2.3</Version>`。
|
||||
- 定义全局公司、版权、产品名称等元数据。
|
||||
- 移除各 `.csproj` 中冲突的版本定义。
|
||||
|
||||
### 2. 统一 Inno Setup 打包脚本
|
||||
- **更新 `src/Hua.Todo.Maui/setup.iss`**:
|
||||
- 将版本号更新为 `1.2.3`。
|
||||
- 确保安装路径与应用名称一致。
|
||||
- **为 `src/Hua.Todo.Avalonia` 创建 `setup.iss`**:
|
||||
- 参考 Maui 的脚本,调整 `Source` 路径为 Avalonia 的发布路径:`bin\Release\net10.0\win-x64\publish\*`。
|
||||
- 调整可执行文件名称为 `Hua.Todo.Avalonia.exe`。
|
||||
- 调整 AppId 以避免与 Maui 版本冲突。
|
||||
|
||||
### 3. 发布流程标准化
|
||||
- 以后发布时,只需修改根目录下的 `Directory.Build.props` 即可同步所有项目的版本。
|
||||
- 执行 `dotnet publish -c Release -r win-x64` 后,手动或通过脚本运行 `ISCC setup.iss` 生成安装包。
|
||||
|
||||
## 预期效果
|
||||
- 所有程序集(DLL/EXE)的版本号均显示为 `1.2.3`。
|
||||
- 提供 `Hua.Todo_Avalonia_Setup_v1.2.3.exe` 和 `Hua.Todo_Maui_Setup_v1.2.3.exe` 两个安装包。
|
||||
@@ -120,6 +120,8 @@ v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结
|
||||
|
||||
## 关键实现点(建议)
|
||||
|
||||
详细实现方案请参考:[06.1-CloudSync-服务端安全设计方案.md](file:///d:/Proj/6.Hua.Todo/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md)
|
||||
|
||||
1. 用户隔离
|
||||
- 服务端所有读写必须绑定当前登录用户上下文
|
||||
2. 权限模型(RBAC)
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
|
||||
## 依赖
|
||||
|
||||
- 依赖 `04-*` 提供策略下发与二次认证能力(至少一种实现)
|
||||
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约)
|
||||
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
|
||||
- 依赖 `05-*` 的基础登录/同步 UI 入口
|
||||
|
||||
## 关键设计点(v1.2.0 必须明确)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 06.1 - 云同步(安全进阶):服务端账户/凭据与策略实现方案
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为 `04-*`(基础能力)与 `06-*`(落盘策略)提供服务端底层安全实现的具体方案,确保账户密码存储、Token 管理、二次认证及安全策略下发具备生产级安全性。
|
||||
|
||||
## 2. 账户与密码安全存储
|
||||
|
||||
服务端不应存储明文密码,需采用强哈希算法进行加盐处理。
|
||||
|
||||
### 存储结构 (Users 表)
|
||||
|
||||
- `UserId`: `Guid` (主键)
|
||||
- `UserName`: `string` (唯一索引)
|
||||
- `PasswordHash`: `string` (存储哈希后的结果)
|
||||
- `PasswordSalt`: `string` (若哈希算法自带 Salt,如 BCrypt/Argon2,则可省略)
|
||||
- `Role`: `string` (用户角色,如 admin/user)
|
||||
- `CreatedAtUtc`: `DateTime`
|
||||
- `UpdatedAtUtc`: `DateTime`
|
||||
|
||||
### 哈希算法建议
|
||||
|
||||
- **算法**: `Argon2id` (推荐) 或 `BCrypt`。
|
||||
- **配置**: 迭代次数、内存占用、并行度应符合 OWASP 最新建议。
|
||||
|
||||
***
|
||||
|
||||
## 3. Token 管理 (JWT)
|
||||
|
||||
采用 JSON Web Token (JWT) 作为会话凭据,并支持细粒度权限控制。
|
||||
|
||||
### Token 结构 (Payload)
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "UserId",
|
||||
"name": "UserName",
|
||||
"role": "Role",
|
||||
"perms": ["tasks:read", "sync:write", "policy:read"],
|
||||
"iat": 1712000000,
|
||||
"exp": 1712003600,
|
||||
"iss": "Hua.Todo.Server",
|
||||
"aud": "Hua.Todo.Client",
|
||||
"isStepUp": false // 是否通过了二次认证
|
||||
}
|
||||
```
|
||||
|
||||
### 校验逻辑
|
||||
|
||||
- 每次请求需携带 `Authorization: Bearer {token}`。
|
||||
- 服务端验证签名、有效期(exp)、签发者(iss)及受众(aud)。
|
||||
- 权限校验:中间件检查 `perms` 声明是否包含当前接口所需的权限。
|
||||
|
||||
***
|
||||
|
||||
## 4. 二次认证与会话提升 (Step-up)
|
||||
|
||||
针对高风险操作(如 `sync:write`、`policy:write`),要求会话必须处于“提升状态”。
|
||||
|
||||
### 流程设计
|
||||
|
||||
1. **触发**: 客户端调用 `/sync`,服务端检查 Token 的 `isStepUp` 声明。
|
||||
2. **拒绝**: 若 `isStepUp == false`,返回 `403 SECOND_FACTOR_REQUIRED`。
|
||||
3. **挑战**: 客户端调用 `/auth/step-up`,提供二次认证凭据(如再次输入密码,或 TOTP 验证码)。
|
||||
4. **提升**: 验证成功后,服务端颁发一个新的 Token,其中 `isStepUp: true`,且有效期较短(如 30 分钟)。
|
||||
5. **重试**: 客户端携带新 Token 重新发起请求。
|
||||
|
||||
***
|
||||
|
||||
## 5. 安全策略 (Security Policy) 存储与下发
|
||||
|
||||
安全策略决定了客户端的“落盘”行为及二次认证频率。
|
||||
|
||||
### 策略定义 (SecurityPolicies 表)
|
||||
|
||||
- `Id`: `int` (主键)
|
||||
- `UserId`: `Guid` (外键)
|
||||
- `AllowPersist`: `bool` (是否允许客户端持久化任务数据)
|
||||
- `AllowSync`: `bool` (是否允许该用户同步)
|
||||
- `SecondFactorExpiryMinutes`: `int` (二次认证状态保持时长,默认 30)
|
||||
- `IsTrustedDeviceOnly`: `bool` (是否仅限受信任终端)
|
||||
|
||||
### 下发逻辑
|
||||
|
||||
- 客户端通过 `GET /security/policy` 获取。
|
||||
- 策略应与 `UserId` 绑定,允许管理员针对不同用户/角色进行差异化配置。
|
||||
|
||||
***
|
||||
|
||||
## 6. 审计日志 (Audit Logs)
|
||||
|
||||
记录关键安全事件,便于追溯。
|
||||
|
||||
- 登录成功/失败(记录 IP、终端信息)。
|
||||
- 高风险操作尝试(是否通过二次认证)。
|
||||
- 安全策略变更。
|
||||
|
||||
***
|
||||
|
||||
## 8. 客户端 UI 与交互设计
|
||||
|
||||
### 8.1 二次认证 (Step-up) 交互流程
|
||||
|
||||
1. **静默拦截**: 当用户触发高风险操作(如点击“立即同步”且 Token 已过期或未提升)时,客户端拦截请求并检测到 `403 SECOND_FACTOR_REQUIRED`。
|
||||
2. **弹出对话框**: 弹出“安全验证”模态框。
|
||||
- **标题**: 需要二次认证。
|
||||
- **描述**: “为了保护您的数据安全,执行此操作需要验证身份。”
|
||||
- **输入**: 密码输入框(或 TOTP 验证码输入框)。
|
||||
- **操作**: \[取消] \[验证并继续]。
|
||||
3. **状态保持**: 验证成功后,UI 应显示短暂的“验证成功”提示,并自动重试刚才被拦截的操作。
|
||||
|
||||
### 8.2 客户端 UI (针对普通用户)
|
||||
|
||||
在客户端“设置 -> 云同步 -> 安全”路径下:
|
||||
|
||||
- **落盘策略 (AllowPersist)**:
|
||||
- **展示**: 状态开关(Toggle)。
|
||||
- **交互**:
|
||||
- 若由服务端强制禁止,开关应为禁用状态(Disabled),并附带说明:“受服务端策略限制,当前终端禁止落盘”。
|
||||
- 若允许修改,关闭开关时应弹出强提醒:“关闭后,所有本地任务数据将被立即清除,退出应用后数据将不再保留。确定继续吗?”
|
||||
- **二次认证频率**:
|
||||
- **展示**: 显示当前二次认证的有效期(如 30 分钟)。
|
||||
|
||||
### 8.3 “不可落盘”模式下的视觉提示
|
||||
|
||||
当 `allowPersist == false` 时:
|
||||
|
||||
- **状态栏/标题栏**: 增加“无痕模式”或“内存存储”小图标/文字提醒。
|
||||
- **登录页**: 增加提醒:“当前环境配置为禁止落盘,数据仅在本次运行期间有效”。
|
||||
- **退出应用**: 点击退出时,若有未同步数据,强提醒:“数据未同步且本地禁止落盘,退出将导致数据丢失。确定退出吗?”
|
||||
|
||||
***
|
||||
|
||||
## 9. 服务端管理后台 (Admin Dashboard)
|
||||
|
||||
为了避免直接操作数据库,服务端需提供一套 Web 管理后台,供管理员维护账户、权限与安全策略。
|
||||
|
||||
### 9.1 工程位置与实现
|
||||
|
||||
- **宿主项目**: [Hua.Todo.Host](file:///d:/Proj/6.Hua.Todo/src/Hua.Todo.Host)
|
||||
- **实现方式**:
|
||||
- 前端采用 **Vue 3 + Vite** 开发。
|
||||
- 静态资源在构建后嵌入到 `Hua.Todo.Host` 的 `wwwroot` 目录或作为内嵌资源。
|
||||
- 后端通过 ASP.NET Core 的 `UseStaticFiles()` 托管,并确保 `/admin` 路由指向前端入口。
|
||||
|
||||
### 9.2 系统初始化 (Bootstrap)
|
||||
|
||||
- **触发条件**: 当检测到数据库中无任何用户时,访问 `/admin` 自动跳转至 `/admin/bootstrap`。
|
||||
- **功能**: 创建首个“超级管理员”账号。
|
||||
- **UI**: 简单的表单(用户名、密码、确认密码)。
|
||||
|
||||
### 9.3 用户管理 (User Management)
|
||||
|
||||
- **用户列表**: 展示所有已注册用户(UserId, UserName, Role, CreatedAt)。
|
||||
- **创建用户**: 管理员手动添加用户(用于内部系统或受控注册)。
|
||||
- **重置密码**: 为忘记密码的用户生成临时密码或直接重设(需审计记录)。
|
||||
- **禁用/删除**: 软删除或禁用账号。
|
||||
|
||||
### 9.4 安全策略配置 (Security Policy Management)
|
||||
|
||||
- **全局策略**: 设置系统默认的 `allowPersist`、`allowSync` 及 `SecondFactorExpiry`。
|
||||
- **单用户覆盖**: 在用户详情页中,管理员可以针对特定高风险用户/终端覆盖全局策略(例如:对特定外包人员账号强制 `allowPersist = false`)。
|
||||
|
||||
### 9.5 会话与凭据管理 (Session Management)
|
||||
|
||||
- **在线会话查看**: 展示当前所有有效的 Token/会话(记录 IP、最后活跃时间、是否已提升权限)。
|
||||
- **强制下线 (Revoke)**: 管理员可一键吊销特定用户的所有 Token(将其加入黑名单)。
|
||||
|
||||
### 9.6 审计日志查看器 (Audit Log Viewer)
|
||||
|
||||
- **过滤查询**: 按时间、用户、事件类型(登录、同步、策略变更)过滤。
|
||||
- **高亮显示**: 对“二次认证失败”、“异常 IP 登录”等敏感事件进行红色高亮。
|
||||
|
||||
***
|
||||
|
||||
## 10. 约束与风险
|
||||
|
||||
- **Token 吊销**: 默认 JWT 是无状态的。若需支持强制下线,需引入 Redis/DB 黑名单。
|
||||
- **HTTPS**: 所有安全通信必须基于 TLS,防止中间人攻击窃取 Token。
|
||||
- **暴力破解**: 需在 `POST /auth/login` 接口增加限流(Rate Limiting)机制。
|
||||
|
||||
@@ -21,19 +21,19 @@
|
||||
## 验收清单(建议逐项勾选)
|
||||
|
||||
### Linux
|
||||
- [ ] Linux 上可启动并打开主界面
|
||||
- [ ] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
|
||||
- [ ] 前端能成功请求本地 `/api/*` 并加载任务列表
|
||||
- [ ] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行
|
||||
- [x] Linux 上可启动并打开主界面
|
||||
- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
|
||||
- [x] 前端能成功请求本地 `/api/*` 并加载任务列表
|
||||
- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md)
|
||||
|
||||
### Search
|
||||
- [ ] 主界面有搜索框
|
||||
- [ ] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
|
||||
- [ ] 清空后恢复原列表
|
||||
- [x] 主界面有搜索框
|
||||
- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
|
||||
- [x] 清空后恢复原列表
|
||||
|
||||
### 云同步(基础可用)
|
||||
- [ ] 可手动配置服务端地址,并有基础校验与风险提示
|
||||
- [ ] 登录后能拉取该用户任务并展示
|
||||
- [ ] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
|
||||
- [ ] 高风险操作触发二次认证(服务端支持时)
|
||||
- [x] 可手动配置服务端地址,并有基础校验与风险提示
|
||||
- [x] 登录后能拉取该用户任务并展示
|
||||
- [x] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
|
||||
- [x] 高风险操作触发二次认证(服务端支持时)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# Hua.Todo AppRun script for AppImage
|
||||
|
||||
HERE=$(dirname $(readlink -f $0))
|
||||
export LD_LIBRARY_PATH=$HERE/usr/lib:$LD_LIBRARY_PATH
|
||||
export PATH=$HERE/usr/bin:$PATH
|
||||
|
||||
exec "$HERE/Hua.Todo.Avalonia" "$@"
|
||||
@@ -0,0 +1,39 @@
|
||||
# Hua.Todo Linux Packaging (v1.2.0+)
|
||||
|
||||
本项目提供 Linux 平台的交付产物支持。目前在 Windows 构建环境下产出 `.tar.gz` 压缩包。
|
||||
|
||||
## 1. AppImage 打包 (Recommended)
|
||||
|
||||
AppImage 是 Linux 下主流的自包含、即插即用发布格式。
|
||||
|
||||
### 前提条件
|
||||
- 在 Linux (Ubuntu/Debian 等) 环境下执行。
|
||||
- 安装 `appimagetool`。
|
||||
|
||||
### 制作步骤
|
||||
1. 解压 `hua.todo-{version}-linux-x64.tar.gz` 到 `AppDir` 目录。
|
||||
2. 将 `pack/linux/AppRun` 复制到 `AppDir/AppRun` 并赋予执行权限。
|
||||
3. 将 `pack/linux/hua.todo.desktop` 复制到 `AppDir/hua.todo.desktop`。
|
||||
4. 将图标 `src/Hua.Todo.Avalonia/icon.ico` (或 png 版本) 复制到 `AppDir/appicon.png`。
|
||||
5. 执行打包命令:
|
||||
```bash
|
||||
appimagetool AppDir/ Hua.Todo-x86_64.AppImage
|
||||
```
|
||||
|
||||
## 2. Flatpak 打包
|
||||
|
||||
Flatpak 提供更好的沙盒隔离与应用商店分发支持。
|
||||
|
||||
### 前提条件
|
||||
- 安装 `flatpak-builder`。
|
||||
|
||||
### 制作步骤
|
||||
1. 根据 `pack/linux/com.hua.todo.json` (待完善) 的描述配置 manifest。
|
||||
2. 使用 `flatpak-builder` 构建。
|
||||
|
||||
## 3. 直接分发 (.tar.gz)
|
||||
|
||||
这是目前 `publish-linux.ps1` 默认产出的格式。
|
||||
|
||||
1. 解压后直接运行 `Hua.Todo.Avalonia` 即可。
|
||||
2. 依赖项:`libwebkit2gtk-4.0-37` (用于 WebView)。
|
||||
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Hua.Todo
|
||||
Comment=Hua.Todo - Cross-platform Todo App (Avalonia)
|
||||
Exec=AppRun
|
||||
Icon=appicon
|
||||
Type=Application
|
||||
Categories=Office;Utility;
|
||||
Terminal=false
|
||||
X-AppImage-Name=Hua.Todo
|
||||
X-AppImage-Version=1.2.8
|
||||
@@ -7,21 +7,31 @@
|
||||
|
||||
约束:
|
||||
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*)
|
||||
- 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链
|
||||
- 仅打包为 tar.gz;Flatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
|
||||
#>
|
||||
|
||||
param(
|
||||
[ValidateSet("linux-x64", "linux-arm64")]
|
||||
[string]$RuntimeIdentifier = "linux-x64",
|
||||
|
||||
[string]$TargetFramework = "net10.0",
|
||||
|
||||
[switch]$SelfContained,
|
||||
|
||||
[string]$Configuration = "Release"
|
||||
[switch]$SkipProcessStop,
|
||||
|
||||
[switch]$SkipRestore,
|
||||
|
||||
[ValidateSet("Release", "Debug")]
|
||||
[string]$Configuration = "Release",
|
||||
|
||||
[string]$BaseIntermediateOutputPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptPath = $PSScriptRoot
|
||||
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
|
||||
|
||||
function Get-FirstExistingFilePath {
|
||||
param(
|
||||
@@ -41,10 +51,23 @@ function Get-FirstExistingFilePath {
|
||||
function Read-ProjectVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProjectFile
|
||||
[string]$ProjectFile,
|
||||
|
||||
[string]$DirectoryBuildProps
|
||||
)
|
||||
|
||||
$currentVersion = "0.0.0"
|
||||
|
||||
# 1. Try Directory.Build.props first
|
||||
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
|
||||
[xml]$props = Get-Content $DirectoryBuildProps -Raw
|
||||
$versionNode = $props.SelectSingleNode("//Version")
|
||||
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
|
||||
return $versionNode.InnerText.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Try .csproj
|
||||
[xml]$csproj = Get-Content $ProjectFile -Raw
|
||||
|
||||
$versionNode = $csproj.SelectSingleNode("//Version")
|
||||
@@ -60,6 +83,38 @@ function Read-ProjectVersion {
|
||||
return $currentVersion
|
||||
}
|
||||
|
||||
function Stop-ProjectProcesses {
|
||||
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
|
||||
dotnet build-server shutdown | Out-Null
|
||||
|
||||
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
|
||||
|
||||
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
|
||||
$processesToKill = Get-Process | Where-Object {
|
||||
$_.ProcessName -like "*Hua.Todo*" -or
|
||||
$_.ProcessName -eq "MSBuild" -or
|
||||
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
|
||||
}
|
||||
|
||||
if ($processesToKill) {
|
||||
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
|
||||
foreach ($p in $processesToKill) {
|
||||
try {
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2 # Give OS more time to release file handles
|
||||
} else {
|
||||
Write-Host "No conflicting processes found." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if (!$SkipProcessStop.IsPresent) {
|
||||
Stop-ProjectProcesses
|
||||
}
|
||||
|
||||
$candidateProjects = @(
|
||||
(Join-Path $ScriptPath "src\Hua.Todo.Avalonia\Hua.Todo.Avalonia.csproj"),
|
||||
(Join-Path $ScriptPath "src\Hua.Todo.Desktop.Avalonia\Hua.Todo.Desktop.Avalonia.csproj")
|
||||
@@ -77,7 +132,8 @@ $candidatesText
|
||||
exit 1
|
||||
}
|
||||
|
||||
$version = Read-ProjectVersion -ProjectFile $ProjectFile
|
||||
$version = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
|
||||
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
|
||||
$artifactRoot = Join-Path $ScriptPath "artifacts\linux\$RuntimeIdentifier"
|
||||
$publishDir = Join-Path $artifactRoot "publish"
|
||||
|
||||
@@ -88,16 +144,57 @@ New-Item -ItemType Directory -Path $publishDir | Out-Null
|
||||
|
||||
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
|
||||
|
||||
Write-Host "Publishing (RID=$RuntimeIdentifier, SelfContained=$selfContainedValue)..." -ForegroundColor Cyan
|
||||
if ($Configuration -ne "Release") {
|
||||
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
|
||||
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
dotnet publish $ProjectFile `
|
||||
-c $Configuration `
|
||||
-r $RuntimeIdentifier `
|
||||
--self-contained $selfContainedValue `
|
||||
-o $publishDir
|
||||
if (!$SkipRestore.IsPresent) {
|
||||
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
|
||||
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
|
||||
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||
# Ensure trailing slash and avoid backslash escaping the quote in CLI
|
||||
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
|
||||
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
|
||||
}
|
||||
& dotnet @restoreArgs
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "dotnet publish failed"
|
||||
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
|
||||
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
|
||||
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
|
||||
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
|
||||
}
|
||||
& dotnet @cleanArgs
|
||||
}
|
||||
|
||||
$maxAttempts = 3
|
||||
$publishSuccess = $false
|
||||
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
|
||||
Write-Host "Publishing (RID=$RuntimeIdentifier, TFM=$TargetFramework, SelfContained=$selfContainedValue, Config=$Configuration, Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
|
||||
$publishArgs = @("publish", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "--framework", $TargetFramework, "--self-contained", $selfContainedValue, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "-o", $publishDir)
|
||||
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
|
||||
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
|
||||
}
|
||||
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
|
||||
$exitCode = $LASTEXITCODE
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
$publishSuccess = $true
|
||||
break
|
||||
}
|
||||
|
||||
Write-Host $publishOutput
|
||||
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
|
||||
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 3
|
||||
Stop-ProjectProcesses # Try killing processes again before retry
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Error "dotnet publish failed after $attempt attempts."
|
||||
exit 1
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,64 @@
|
||||
param(
|
||||
[ValidateSet("Maui", "Avalonia")]
|
||||
[string]$AppType = "Maui",
|
||||
|
||||
[string]$TargetFramework = "net10.0-windows10.0.19041.0",
|
||||
|
||||
[string]$RuntimeIdentifier = "win-x64",
|
||||
|
||||
[ValidateSet("Release", "Debug")]
|
||||
[string]$Configuration = "Release",
|
||||
|
||||
[switch]$SkipInnoSetup,
|
||||
|
||||
[switch]$SkipVersionBump,
|
||||
|
||||
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier))
|
||||
[switch]$SkipProcessStop,
|
||||
|
||||
[switch]$SkipRestore,
|
||||
|
||||
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)),
|
||||
|
||||
[string]$BaseIntermediateOutputPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptPath = $PSScriptRoot
|
||||
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
|
||||
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
|
||||
|
||||
if ($AppType -eq "Avalonia") {
|
||||
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Avalonia"
|
||||
$TargetFramework = "net10.0"
|
||||
} else {
|
||||
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
|
||||
}
|
||||
|
||||
$ProjectFile = Get-ChildItem -Path $ProjectDir -Filter "*.csproj" | Select-Object -First 1 -ExpandProperty FullName
|
||||
$SetupScript = Join-Path $ProjectDir "setup.iss"
|
||||
$ProjectBaseName = [System.IO.Path]::GetFileNameWithoutExtension($ProjectFile)
|
||||
|
||||
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
|
||||
|
||||
function Read-ProjectVersion {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ProjectFile
|
||||
[string]$ProjectFile,
|
||||
|
||||
[string]$DirectoryBuildProps
|
||||
)
|
||||
|
||||
$currentVersion = "0.0.0"
|
||||
|
||||
# 1. Try Directory.Build.props first
|
||||
if ($null -ne $DirectoryBuildProps -and (Test-Path $DirectoryBuildProps)) {
|
||||
[xml]$props = Get-Content $DirectoryBuildProps -Raw
|
||||
$versionNode = $props.SelectSingleNode("//Version")
|
||||
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
|
||||
return $versionNode.InnerText.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Try .csproj
|
||||
[xml]$csproj = Get-Content $ProjectFile -Raw
|
||||
|
||||
$versionNode = $csproj.SelectSingleNode("//Version")
|
||||
@@ -153,14 +185,93 @@ function Copy-Directory {
|
||||
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
|
||||
}
|
||||
|
||||
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile
|
||||
function Stop-ProjectProcesses {
|
||||
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
|
||||
dotnet build-server shutdown | Out-Null
|
||||
|
||||
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
|
||||
|
||||
# Aggressively look for anything related to the project or MSBuild/dotnet background tasks
|
||||
$processesToKill = Get-Process | Where-Object {
|
||||
$_.ProcessName -like "*Hua.Todo*" -or
|
||||
$_.ProcessName -eq "MSBuild" -or
|
||||
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
|
||||
}
|
||||
|
||||
if ($processesToKill) {
|
||||
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
|
||||
foreach ($p in $processesToKill) {
|
||||
try {
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2 # Give OS more time to release file handles
|
||||
} else {
|
||||
Write-Host "No conflicting processes found." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if (!$SkipProcessStop.IsPresent) {
|
||||
Stop-ProjectProcesses
|
||||
}
|
||||
|
||||
$currentVersion = Read-ProjectVersion -ProjectFile $ProjectFile -DirectoryBuildProps $DirectoryBuildProps
|
||||
Update-InnoSetupVersion -SetupScript $SetupScript -Version $currentVersion
|
||||
|
||||
Write-Host "Publishing Hua.Todo.Maui (TFM=$TargetFramework, RID=$RuntimeIdentifier, Config=$Configuration)..." -ForegroundColor Cyan
|
||||
if ($Configuration -ne "Release") {
|
||||
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
|
||||
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
if (!$SkipRestore.IsPresent) {
|
||||
Write-Host "Restoring $ProjectBaseName for $RuntimeIdentifier ($TargetFramework)..." -ForegroundColor Yellow
|
||||
$restoreArgs = @("restore", $ProjectFile, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
|
||||
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||
# Ensure trailing slash and avoid backslash escaping the quote in CLI
|
||||
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
|
||||
$restoreArgs += "-p:BaseIntermediateOutputPath=$path"
|
||||
}
|
||||
& dotnet @restoreArgs
|
||||
|
||||
Write-Host "Cleaning $ProjectBaseName ($TargetFramework)..." -ForegroundColor Yellow
|
||||
$cleanArgs = @("clean", $ProjectFile, "-c", $Configuration, "-r", $RuntimeIdentifier, "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true", "--verbosity", "minimal")
|
||||
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
|
||||
$cleanArgs += "-p:BaseIntermediateOutputPath=$path"
|
||||
}
|
||||
& dotnet @cleanArgs
|
||||
}
|
||||
|
||||
# UseMonoRuntime 在 csproj 内按 TargetFramework 做了条件配置:仅 Android 启用,其它目标关闭。
|
||||
dotnet publish $ProjectFile -f $TargetFramework -c $Configuration -r $RuntimeIdentifier --self-contained false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "MAUI build failed"
|
||||
$maxAttempts = 3
|
||||
$publishSuccess = $false
|
||||
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
|
||||
Write-Host "Publishing $ProjectBaseName (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
|
||||
$publishArgs = @("publish", $ProjectFile, "--framework", $TargetFramework, "-c", $Configuration, "-r", $RuntimeIdentifier, "--self-contained", "false", "-p:IsDesktopBuild=true", "-p:SkipWebBuild=true")
|
||||
if (![string]::IsNullOrWhiteSpace($BaseIntermediateOutputPath)) {
|
||||
$path = $BaseIntermediateOutputPath.TrimEnd('\') + '\'
|
||||
$publishArgs += "-p:BaseIntermediateOutputPath=$path"
|
||||
}
|
||||
$publishOutput = (& dotnet @publishArgs 2>&1 | Out-String)
|
||||
$exitCode = $LASTEXITCODE
|
||||
|
||||
if ($exitCode -eq 0) {
|
||||
$publishSuccess = $true
|
||||
break
|
||||
}
|
||||
|
||||
Write-Host $publishOutput
|
||||
if ($attempt -lt $maxAttempts -and ($publishOutput -match 'Access to the path .* is denied|being used by another process|Sharing violation')) {
|
||||
Write-Host "⚠️ Build failed due to file lock. Retrying in 3 seconds..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 3
|
||||
Stop-ProjectProcesses # Try killing processes again before retry
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Error "MAUI build failed after $attempt attempts."
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -192,6 +303,7 @@ if (!(Test-Path $desiredExePath)) {
|
||||
$candidateExe = $null
|
||||
$preferredCandidateNames = @(
|
||||
"Hua.Todo.Maui.exe",
|
||||
"Hua.Todo.Avalonia.exe",
|
||||
"Hua.Todo.exe"
|
||||
)
|
||||
foreach ($name in $preferredCandidateNames) {
|
||||
@@ -217,19 +329,21 @@ if (!(Test-Path $desiredExePath)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
|
||||
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
|
||||
if (!(Test-Path $mauiIndexPath)) {
|
||||
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
|
||||
exit 1
|
||||
}
|
||||
if ($AppType -eq "Maui") {
|
||||
$mauiWwwrootDir = Join-Path $ProjectDir "wwwroot"
|
||||
$mauiIndexPath = Join-Path $mauiWwwrootDir "index.html"
|
||||
if (!(Test-Path $mauiIndexPath)) {
|
||||
Write-Error "MAUI wwwroot not found: $mauiIndexPath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$publishWwwroot = Join-Path $publishDir "wwwroot"
|
||||
if (Test-Path $publishWwwroot) {
|
||||
Remove-Item -Recurse -Force $publishWwwroot
|
||||
$publishWwwroot = Join-Path $publishDir "wwwroot"
|
||||
if (Test-Path $publishWwwroot) {
|
||||
Remove-Item -Recurse -Force $publishWwwroot
|
||||
}
|
||||
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
|
||||
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
|
||||
}
|
||||
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
|
||||
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
|
||||
|
||||
$installerPath = $null
|
||||
if (!$SkipInnoSetup.IsPresent) {
|
||||
@@ -255,6 +369,7 @@ if (!$SkipInnoSetup.IsPresent) {
|
||||
# ISCC sometimes fails with a transient file sharing violation (e.g. antivirus/indexer holding the script/output briefly).
|
||||
$maxAttempts = 3
|
||||
for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
|
||||
Write-Host "Compiling installer (Attempt $attempt/$maxAttempts)..." -ForegroundColor Cyan
|
||||
$isccOutput = (& $ISCC $SetupScript 2>&1 | Out-String)
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -eq 0) {
|
||||
@@ -286,8 +401,7 @@ if (!$SkipInnoSetup.IsPresent) {
|
||||
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
|
||||
}
|
||||
} else {
|
||||
Write-Error "Inno Setup compiler not found"
|
||||
exit 1
|
||||
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +416,8 @@ if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
|
||||
}
|
||||
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
|
||||
|
||||
$installerName = "hua.todo-$currentVersion-$RuntimeIdentifier-setup.exe"
|
||||
$installerPrefix = if ($AppType -eq "Avalonia") { "hua.todo-avalonia" } else { "hua.todo-maui" }
|
||||
$installerName = "$installerPrefix-$currentVersion-$RuntimeIdentifier-setup.exe"
|
||||
Copy-Item -Path $installerPath -Destination (Join-Path $installerArtifactDir $installerName) -Force
|
||||
}
|
||||
}
|
||||
@@ -312,12 +427,22 @@ if (!$SkipVersionBump.IsPresent) {
|
||||
if ($versionMatch.Success) {
|
||||
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
|
||||
|
||||
$content = Get-Content $ProjectFile -Raw
|
||||
if ($content -match "<Version>[^<]*</Version>") {
|
||||
$content = $content -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
||||
Set-Content $ProjectFile -Value $content -Encoding UTF8
|
||||
} else {
|
||||
Write-Host "Skip version bump: <Version> node not found in csproj." -ForegroundColor Yellow
|
||||
# 1. Update Directory.Build.props if exists
|
||||
if (Test-Path $DirectoryBuildProps) {
|
||||
$propsContent = Get-Content $DirectoryBuildProps -Raw
|
||||
if ($propsContent -match "<Version>[^<]*</Version>") {
|
||||
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
||||
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
|
||||
Write-Host "Updated version in Directory.Build.props to $newVersion" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Update .csproj if it still has Version
|
||||
$csprojContent = Get-Content $ProjectFile -Raw
|
||||
if ($csprojContent -match "<Version>[^<]*</Version>") {
|
||||
$csprojContent = $csprojContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
||||
Set-Content $ProjectFile -Value $csprojContent -Encoding UTF8
|
||||
Write-Host "Updated version in $ProjectBaseName.csproj to $newVersion" -ForegroundColor Green
|
||||
}
|
||||
} else {
|
||||
Write-Host "Skip version bump: version is not MAJOR.MINOR.PATCH -> $currentVersion" -ForegroundColor Yellow
|
||||
|
||||
@@ -7,6 +7,7 @@ param(
|
||||
|
||||
[switch]$LinuxSelfContained,
|
||||
|
||||
[ValidateSet("Release", "Debug")]
|
||||
[string]$Configuration = "Release",
|
||||
|
||||
[switch]$SkipWindowsInnoSetup,
|
||||
@@ -17,9 +18,92 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ScriptPath = $PSScriptRoot
|
||||
|
||||
$WebDir = Join-Path $ScriptPath "src\Hua.Todo.Web"
|
||||
$publishWindowsScript = Join-Path $ScriptPath "publish-windows.ps1"
|
||||
$publishLinuxScript = Join-Path $ScriptPath "publish-linux.ps1"
|
||||
$DirectoryBuildProps = Join-Path $ScriptPath "Directory.Build.props"
|
||||
|
||||
function Stop-ProjectProcesses {
|
||||
Write-Host "Shutting down dotnet build servers..." -ForegroundColor Yellow
|
||||
dotnet build-server shutdown | Out-Null
|
||||
|
||||
Write-Host "Checking for running processes to prevent file locks..." -ForegroundColor Yellow
|
||||
|
||||
$processesToKill = Get-Process | Where-Object {
|
||||
$_.ProcessName -like "*Hua.Todo*" -or
|
||||
$_.ProcessName -eq "MSBuild" -or
|
||||
($_.ProcessName -eq "dotnet" -and ($_.CommandLine -like "*Hua.Todo*" -or $_.CommandLine -like "*msbuild*"))
|
||||
}
|
||||
|
||||
if ($processesToKill) {
|
||||
Write-Host "Stopping $($processesToKill.Count) running processes..." -ForegroundColor Yellow
|
||||
foreach ($p in $processesToKill) {
|
||||
try {
|
||||
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Warning "Failed to stop process $($p.ProcessName) (ID: $($p.Id))"
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
} else {
|
||||
Write-Host "No conflicting processes found." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
function Build-Web {
|
||||
Write-Host "Building Web artifacts..." -ForegroundColor Cyan
|
||||
if (!(Test-Path $WebDir)) {
|
||||
Write-Error "Web directory not found: $WebDir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (!(Test-Path (Join-Path $WebDir "node_modules"))) {
|
||||
Write-Host "Installing npm dependencies..." -ForegroundColor Yellow
|
||||
Push-Location $WebDir
|
||||
npm install
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host "Running Web builds in parallel..." -ForegroundColor Yellow
|
||||
$webJobs = @()
|
||||
$webJobs += Start-Job -ScriptBlock {
|
||||
param($dir)
|
||||
Set-Location $dir
|
||||
npm run build
|
||||
} -ArgumentList $WebDir
|
||||
|
||||
$webJobs += Start-Job -ScriptBlock {
|
||||
param($dir)
|
||||
Set-Location $dir
|
||||
npm run build -- --mode maui
|
||||
} -ArgumentList $WebDir
|
||||
|
||||
$webJobs | Wait-Job | Receive-Job
|
||||
}
|
||||
|
||||
function Bump-Version {
|
||||
if (Test-Path $DirectoryBuildProps) {
|
||||
[xml]$props = Get-Content $DirectoryBuildProps -Raw
|
||||
$versionNode = $props.SelectSingleNode("//Version")
|
||||
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
|
||||
$currentVersion = $versionNode.InnerText.Trim()
|
||||
$versionMatch = [regex]::Match($currentVersion, '^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$')
|
||||
if ($versionMatch.Success) {
|
||||
$newVersion = "{0}.{1}.{2}" -f $versionMatch.Groups["major"].Value, $versionMatch.Groups["minor"].Value, ([int]$versionMatch.Groups["patch"].Value + 1)
|
||||
$propsContent = Get-Content $DirectoryBuildProps -Raw
|
||||
$propsContent = $propsContent -replace "<Version>[^<]*</Version>", "<Version>$newVersion</Version>"
|
||||
Set-Content $DirectoryBuildProps -Value $propsContent -Encoding UTF8
|
||||
Write-Host "Bumped version in Directory.Build.props: $currentVersion -> $newVersion" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($Configuration -ne "Release") {
|
||||
Write-Host "⚠️ WARNING: You are publishing in $Configuration configuration!" -ForegroundColor Yellow
|
||||
Write-Host " Typically, production artifacts MUST be in Release configuration." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
$shouldPublishWindows = $Windows.IsPresent
|
||||
$shouldPublishLinux = $Linux.IsPresent
|
||||
@@ -28,36 +112,36 @@ if (!$shouldPublishWindows -and !$shouldPublishLinux) {
|
||||
$shouldPublishLinux = $true
|
||||
}
|
||||
|
||||
# 1. Cleanup
|
||||
Stop-ProjectProcesses
|
||||
|
||||
# 2. Build Web
|
||||
Build-Web
|
||||
|
||||
# 3. App Publishing
|
||||
# NOTE: To avoid file locks in 'obj' and 'bin' folders, we publish the .NET apps sequentially.
|
||||
# However, we can parallelize the Inno Setup packaging later if needed.
|
||||
Write-Host "Starting app publishing..." -ForegroundColor Cyan
|
||||
|
||||
if ($shouldPublishWindows) {
|
||||
if (!(Test-Path $publishWindowsScript)) {
|
||||
Write-Error "publish-windows.ps1 not found: $publishWindowsScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
& $publishWindowsScript `
|
||||
-Configuration $Configuration `
|
||||
-SkipInnoSetup:$SkipWindowsInnoSetup `
|
||||
-SkipVersionBump:$SkipWindowsVersionBump
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
Write-Host "Publishing Windows Apps..." -ForegroundColor Cyan
|
||||
# Maui Windows (Skip ISS here, we'll do it later if parallel is needed, or just let it run)
|
||||
& $publishWindowsScript -AppType Maui -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
|
||||
|
||||
# Avalonia Windows
|
||||
& $publishWindowsScript -AppType Avalonia -Configuration $Configuration -SkipInnoSetup:$SkipWindowsInnoSetup -SkipVersionBump -SkipProcessStop
|
||||
}
|
||||
|
||||
if ($shouldPublishLinux) {
|
||||
if (!(Test-Path $publishLinuxScript)) {
|
||||
Write-Error "publish-linux.ps1 not found: $publishLinuxScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Publishing Linux Apps..." -ForegroundColor Cyan
|
||||
foreach ($rid in $LinuxRuntimes) {
|
||||
& $publishLinuxScript `
|
||||
-RuntimeIdentifier $rid `
|
||||
-SelfContained:$LinuxSelfContained `
|
||||
-Configuration $Configuration
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
& $publishLinuxScript -RuntimeIdentifier $rid -SelfContained:$LinuxSelfContained -Configuration $Configuration -SkipProcessStop
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Finalizing
|
||||
if (!$SkipWindowsVersionBump.IsPresent) {
|
||||
Bump-Version
|
||||
}
|
||||
|
||||
Write-Host "All publishing tasks completed!" -ForegroundColor Green
|
||||
|
||||
@@ -21,24 +21,41 @@ public static class CloudSyncEndpointExtensions
|
||||
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
|
||||
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
|
||||
auth.MapPost("/login", LoginAsync).AllowAnonymous();
|
||||
auth.MapPost("/step-up", StepUpAsync).AllowAnonymous();
|
||||
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization();
|
||||
|
||||
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
|
||||
tasks.MapGet("/", GetTasksAsync).AllowAnonymous();
|
||||
tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read");
|
||||
|
||||
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
|
||||
sync.MapPost("/", SyncAsync).AllowAnonymous();
|
||||
sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write");
|
||||
|
||||
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
|
||||
security.MapGet("/policy", GetPolicyAsync).AllowAnonymous();
|
||||
security.MapPut("/policy", UpdatePolicyAsync).AllowAnonymous();
|
||||
security.MapGet("/policy", GetPolicyAsync).RequireAuthorization("policy:read");
|
||||
security.MapPut("/policy", UpdatePolicyAsync).RequireAuthorization("policy:write");
|
||||
|
||||
var admin = app.MapGroup("/admin").WithTags("CloudSync - Admin").RequireAuthorization("users:manage");
|
||||
admin.MapGet("/users", GetUsersAsync);
|
||||
admin.MapPost("/users", CreateUserAsync);
|
||||
admin.MapPost("/users/{userId}/reset-password", ResetPasswordAsync);
|
||||
admin.MapDelete("/users/{userId}", DeleteUserAsync);
|
||||
admin.MapGet("/sessions", GetSessionsAsync);
|
||||
admin.MapDelete("/sessions/{sessionId}", RevokeSessionAsync);
|
||||
admin.MapGet("/audit-logs", GetAuditLogsAsync);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static (string? ip, string? ua) GetClientInfo(HttpContext httpContext)
|
||||
{
|
||||
var ip = httpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var ua = httpContext.Request.Headers.UserAgent.ToString();
|
||||
return (ip, ua);
|
||||
}
|
||||
|
||||
private static async Task<IResult> BootstrapAdminAsync(
|
||||
BootstrapAdminRequest request,
|
||||
CloudAuthService authService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
||||
@@ -46,7 +63,8 @@ public static class CloudSyncEndpointExtensions
|
||||
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
||||
}
|
||||
|
||||
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, cancellationToken);
|
||||
var (ip, ua) = GetClientInfo(httpContext);
|
||||
var ok = await authService.BootstrapAdminAsync(request.UserName, request.Password, ip, ua, cancellationToken);
|
||||
if (!ok)
|
||||
{
|
||||
return CloudApiErrors.Forbidden("Bootstrap is not allowed (already initialized or invalid input).");
|
||||
@@ -58,6 +76,7 @@ public static class CloudSyncEndpointExtensions
|
||||
private static async Task<IResult> LoginAsync(
|
||||
LoginRequest request,
|
||||
CloudAuthService authService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
||||
@@ -65,7 +84,8 @@ public static class CloudSyncEndpointExtensions
|
||||
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
||||
}
|
||||
|
||||
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), cancellationToken);
|
||||
var (ip, ua) = GetClientInfo(httpContext);
|
||||
var response = await authService.LoginAsync(request.UserName, request.Password, TimeSpan.FromDays(7), ip, ua, cancellationToken);
|
||||
if (response == null)
|
||||
{
|
||||
return CloudApiErrors.Unauthorized("Invalid credentials.");
|
||||
@@ -91,7 +111,8 @@ public static class CloudSyncEndpointExtensions
|
||||
return CloudApiErrors.BadRequest("Password is required.");
|
||||
}
|
||||
|
||||
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, TimeSpan.FromMinutes(5), cancellationToken);
|
||||
var (ip, ua) = GetClientInfo(httpContext);
|
||||
var expiresAt = await authService.StepUpAsync(sessionId.Value, request.Password, ip, ua, cancellationToken);
|
||||
if (!expiresAt.HasValue)
|
||||
{
|
||||
return CloudApiErrors.Unauthorized("Invalid credentials or session expired.");
|
||||
@@ -188,7 +209,108 @@ public static class CloudSyncEndpointExtensions
|
||||
return CloudApiErrors.SecondFactorRequired();
|
||||
}
|
||||
|
||||
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, cancellationToken);
|
||||
var policy = await policyService.UpdatePolicyAsync(userId.Value, request.AllowPersist, request.AllowSync, request.SecondFactorExpiryMinutes, request.IsTrustedDeviceOnly, cancellationToken);
|
||||
return Results.Json(policy);
|
||||
}
|
||||
|
||||
#region Admin Endpoints
|
||||
|
||||
private static async Task<IResult> GetUsersAsync(
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: 启用权限校验
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
var users = await adminService.GetUsersAsync(cancellationToken);
|
||||
return Results.Json(users);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateUserAsync(
|
||||
CreateUserRequest request,
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.UserName) || string.IsNullOrWhiteSpace(request.Password))
|
||||
{
|
||||
return CloudApiErrors.BadRequest("UserName and Password are required.");
|
||||
}
|
||||
|
||||
var user = await adminService.CreateUserAsync(request, cancellationToken);
|
||||
if (user == null) return CloudApiErrors.BadRequest("User already exists or creation failed.");
|
||||
|
||||
return Results.Json(user);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ResetPasswordAsync(
|
||||
Guid userId,
|
||||
ResetPasswordRequest request,
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
{
|
||||
return CloudApiErrors.BadRequest("NewPassword is required.");
|
||||
}
|
||||
|
||||
var ok = await adminService.ResetPasswordAsync(userId, request.NewPassword, cancellationToken);
|
||||
return ok ? Results.Ok() : CloudApiErrors.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteUserAsync(
|
||||
Guid userId,
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
var ok = await adminService.DeleteUserAsync(userId, cancellationToken);
|
||||
return ok ? Results.Ok() : CloudApiErrors.BadRequest("User not found or deletion not allowed.");
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSessionsAsync(
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
var sessions = await adminService.GetSessionsAsync(cancellationToken);
|
||||
return Results.Json(sessions);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeSessionAsync(
|
||||
Guid sessionId,
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
var ok = await adminService.RevokeSessionAsync(sessionId, cancellationToken);
|
||||
return ok ? Results.Ok() : CloudApiErrors.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAuditLogsAsync(
|
||||
int count,
|
||||
CloudAdminService adminService,
|
||||
HttpContext httpContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// if (!httpContext.User.HasPermission(CloudPermissions.UsersManage)) return CloudApiErrors.Forbidden();
|
||||
|
||||
if (count <= 0) count = 100;
|
||||
var logs = await adminService.GetAuditLogsAsync(count, cancellationToken);
|
||||
return Results.Json(logs);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public static class CloudSyncServiceCollectionExtensions
|
||||
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
|
||||
|
||||
services.AddScoped<CloudAuthService>();
|
||||
services.AddScoped<CloudAdminService>();
|
||||
services.AddScoped<CloudTaskSyncService>();
|
||||
services.AddScoped<SecurityPolicyService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Hua.Todo.Application.CloudSync.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 用户信息 DTO(管理端使用)。
|
||||
/// </summary>
|
||||
public class UserDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建用户请求。
|
||||
/// </summary>
|
||||
public class CreateUserRequest
|
||||
{
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public string Role { get; set; } = "user";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置密码请求。
|
||||
/// </summary>
|
||||
public class ResetPasswordRequest
|
||||
{
|
||||
public string NewPassword { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 审计日志 DTO。
|
||||
/// </summary>
|
||||
public class AuditLogDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
public Guid? UserId { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? ClientIp { get; set; }
|
||||
public string? UserAgent { get; set; }
|
||||
public bool IsSuccess { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 会话信息 DTO。
|
||||
/// </summary>
|
||||
public class SessionDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public DateTime ExpiresAtUtc { get; set; }
|
||||
public bool IsSteppedUp { get; set; }
|
||||
}
|
||||
@@ -19,6 +19,16 @@ public class SecurityPolicyDto
|
||||
/// 需要二次认证的操作列表(操作码)。
|
||||
/// </summary>
|
||||
public List<string> RequireSecondFactorFor { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 二次认证有效期(分钟)。
|
||||
/// </summary>
|
||||
public int SecondFactorExpiryMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅限受信任设备。
|
||||
/// </summary>
|
||||
public bool IsTrustedDeviceOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -35,4 +45,14 @@ public class UpdateSecurityPolicyRequest
|
||||
/// 是否允许同步写入。
|
||||
/// </summary>
|
||||
public bool AllowSync { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 二次认证有效期(分钟)。
|
||||
/// </summary>
|
||||
public int SecondFactorExpiryMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅限受信任设备。
|
||||
/// </summary>
|
||||
public bool IsTrustedDeviceOnly { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Hua.Todo.Application.CloudSync.Models;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Hua.Todo.Core.Entities;
|
||||
|
||||
namespace Hua.Todo.Application.CloudSync.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 管理端服务(账户、会话、审计、全局策略管理)。
|
||||
/// </summary>
|
||||
public class CloudAdminService
|
||||
{
|
||||
private readonly TodoDbContext _dbContext;
|
||||
private readonly IPasswordHasher<UserEntity> _passwordHasher;
|
||||
|
||||
public CloudAdminService(TodoDbContext dbContext, IPasswordHasher<UserEntity> passwordHasher)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_passwordHasher = passwordHasher;
|
||||
}
|
||||
|
||||
#region User Management
|
||||
|
||||
public async Task<List<UserDto>> GetUsersAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.Users
|
||||
.AsNoTracking()
|
||||
.Where(u => u.Role != "local")
|
||||
.Select(u => new UserDto
|
||||
{
|
||||
Id = u.Id,
|
||||
UserName = u.UserName,
|
||||
Role = u.Role,
|
||||
CreatedAtUtc = u.CreatedAtUtc,
|
||||
UpdatedAtUtc = u.UpdatedAtUtc
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<UserDto?> CreateUserAsync(CreateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUserName = request.UserName.Trim();
|
||||
var exists = await _dbContext.Users.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
|
||||
if (exists) return null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var user = new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = normalizedUserName,
|
||||
Role = request.Role,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, request.Password);
|
||||
_dbContext.Users.Add(user);
|
||||
|
||||
// 为新用户创建默认策略
|
||||
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id });
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new UserDto { Id = user.Id, UserName = user.UserName, Role = user.Role, CreatedAtUtc = user.CreatedAtUtc, UpdatedAtUtc = user.UpdatedAtUtc };
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(Guid userId, string newPassword, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||
if (user == null) return false;
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, newPassword);
|
||||
user.UpdatedAtUtc = DateTime.UtcNow;
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
|
||||
if (user == null || user.Role == "admin") return false; // 不允许通过 API 删除最后一个 admin(通常应有更多保护)
|
||||
|
||||
_dbContext.Users.Remove(user);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Session Management
|
||||
|
||||
public async Task<List<SessionDto>> GetSessionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await _dbContext.UserSessions
|
||||
.AsNoTracking()
|
||||
.Include(s => s.User)
|
||||
.Where(s => s.ExpiresAtUtc > now)
|
||||
.Select(s => new SessionDto
|
||||
{
|
||||
Id = s.Id,
|
||||
UserId = s.UserId,
|
||||
UserName = s.User != null ? s.User.UserName : "Unknown",
|
||||
CreatedAtUtc = s.CreatedAtUtc,
|
||||
ExpiresAtUtc = s.ExpiresAtUtc,
|
||||
IsSteppedUp = s.StepUpExpiresAtUtc.HasValue && s.StepUpExpiresAtUtc.Value > now
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
|
||||
if (session == null) return false;
|
||||
|
||||
_dbContext.UserSessions.Remove(session);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Audit Logs
|
||||
|
||||
public async Task<List<AuditLogDto>> GetAuditLogsAsync(int count, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _dbContext.AuditLogs
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(l => l.TimestampUtc)
|
||||
.Take(count)
|
||||
.Select(l => new AuditLogDto
|
||||
{
|
||||
Id = l.Id,
|
||||
TimestampUtc = l.TimestampUtc,
|
||||
UserId = l.UserId,
|
||||
UserName = l.UserName,
|
||||
EventType = l.EventType,
|
||||
Description = l.Description,
|
||||
ClientIp = l.ClientIp,
|
||||
UserAgent = l.UserAgent,
|
||||
IsSuccess = l.IsSuccess
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -32,14 +32,42 @@ public class CloudAuthService
|
||||
_rolePermissionMapper = rolePermissionMapper;
|
||||
}
|
||||
|
||||
private async Task LogAsync(
|
||||
string eventType,
|
||||
string description,
|
||||
Guid? userId = null,
|
||||
string? userName = null,
|
||||
bool isSuccess = true,
|
||||
string? clientIp = null,
|
||||
string? userAgent = null)
|
||||
{
|
||||
var log = new AuditLogEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TimestampUtc = DateTime.UtcNow,
|
||||
EventType = eventType,
|
||||
Description = description,
|
||||
UserId = userId,
|
||||
UserName = userName,
|
||||
IsSuccess = isSuccess,
|
||||
ClientIp = clientIp,
|
||||
UserAgent = userAgent
|
||||
};
|
||||
|
||||
_dbContext.AuditLogs.Add(log);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化系统管理员账号(仅在系统尚无云用户时可用)。
|
||||
/// </summary>
|
||||
/// <param name="userName">用户名。</param>
|
||||
/// <param name="password">密码。</param>
|
||||
/// <param name="clientIp">客户端 IP。</param>
|
||||
/// <param name="userAgent">User-Agent。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>是否初始化成功。</returns>
|
||||
public async Task<bool> BootstrapAdminAsync(string userName, string password, CancellationToken cancellationToken)
|
||||
public async Task<bool> BootstrapAdminAsync(string userName, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUserName = (userName ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
|
||||
@@ -53,6 +81,7 @@ public class CloudAuthService
|
||||
|
||||
if (hasAnyCloudUser)
|
||||
{
|
||||
await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -65,11 +94,14 @@ public class CloudAuthService
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var user = new UserEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserName = normalizedUserName,
|
||||
Role = "admin"
|
||||
Role = "admin",
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now
|
||||
};
|
||||
|
||||
user.PasswordHash = _passwordHasher.HashPassword(user, password);
|
||||
@@ -78,6 +110,7 @@ public class CloudAuthService
|
||||
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await LogAsync("BootstrapSuccess", "System administrator created.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -87,9 +120,11 @@ public class CloudAuthService
|
||||
/// <param name="userName">用户名。</param>
|
||||
/// <param name="password">密码。</param>
|
||||
/// <param name="sessionTtl">会话有效期。</param>
|
||||
/// <param name="clientIp">客户端 IP。</param>
|
||||
/// <param name="userAgent">User-Agent。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>登录响应;失败则返回 null。</returns>
|
||||
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, CancellationToken cancellationToken)
|
||||
public async Task<LoginResponse?> LoginAsync(string userName, string password, TimeSpan sessionTtl, string? clientIp, string? userAgent, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedUserName = (userName ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
|
||||
@@ -100,12 +135,14 @@ public class CloudAuthService
|
||||
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.UserName == normalizedUserName, cancellationToken);
|
||||
if (user == null || user.Role == "local")
|
||||
{
|
||||
await LogAsync("LoginFailed", "User not found or local user login attempted.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (verify == PasswordVerificationResult.Failed)
|
||||
{
|
||||
await LogAsync("LoginFailed", "Invalid password.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -122,17 +159,17 @@ public class CloudAuthService
|
||||
|
||||
_dbContext.UserSessions.Add(session);
|
||||
|
||||
var hasPolicy = await _dbContext.SecurityPolicies
|
||||
.AsNoTracking()
|
||||
.AnyAsync(p => p.UserId == user.Id, cancellationToken);
|
||||
|
||||
if (!hasPolicy)
|
||||
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
|
||||
if (policy == null)
|
||||
{
|
||||
_dbContext.SecurityPolicies.Add(new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true });
|
||||
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = user.Id, AllowPersist = true };
|
||||
_dbContext.SecurityPolicies.Add(policy);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await LogAsync("LoginSuccess", "User logged in.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||
|
||||
return new LoginResponse
|
||||
{
|
||||
AccessToken = session.Id.ToString(),
|
||||
@@ -148,10 +185,11 @@ public class CloudAuthService
|
||||
/// </summary>
|
||||
/// <param name="sessionId">会话 ID。</param>
|
||||
/// <param name="password">口令。</param>
|
||||
/// <param name="stepUpTtl">二次认证有效期。</param>
|
||||
/// <param name="clientIp">客户端 IP。</param>
|
||||
/// <param name="userAgent">User-Agent。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>有效期截止时间;失败返回 null。</returns>
|
||||
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, TimeSpan stepUpTtl, CancellationToken cancellationToken)
|
||||
public async Task<DateTime?> StepUpAsync(Guid sessionId, string password, string? clientIp, string? userAgent, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
@@ -175,13 +213,20 @@ public class CloudAuthService
|
||||
var verify = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (verify == PasswordVerificationResult.Failed)
|
||||
{
|
||||
await LogAsync("StepUpFailed", "Invalid password during step-up.", user.Id, user.UserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
|
||||
return null;
|
||||
}
|
||||
|
||||
var stepUpExpiresAt = now.Add(stepUpTtl);
|
||||
var policy = await _dbContext.SecurityPolicies.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
|
||||
var expiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30;
|
||||
if (expiryMinutes <= 0) expiryMinutes = 30;
|
||||
|
||||
var stepUpExpiresAt = now.AddMinutes(expiryMinutes);
|
||||
session.StepUpExpiresAtUtc = stepUpExpiresAt;
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await LogAsync("StepUpSuccess", $"Session stepped up for {expiryMinutes} minutes.", user.Id, user.UserName, clientIp: clientIp, userAgent: userAgent);
|
||||
|
||||
return stepUpExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ public class SecurityPolicyService
|
||||
{
|
||||
AllowPersist = policy?.AllowPersist ?? true,
|
||||
AllowSync = policy?.AllowSync ?? true,
|
||||
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" }
|
||||
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
|
||||
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
|
||||
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,20 +48,33 @@ public class SecurityPolicyService
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="allowPersist">是否允许落盘。</param>
|
||||
/// <param name="allowSync">是否允许同步。</param>
|
||||
/// <param name="secondFactorExpiryMinutes">二次认证有效期。</param>
|
||||
/// <param name="isTrustedDeviceOnly">是否仅限受信任设备。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>更新后的策略 DTO。</returns>
|
||||
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, CancellationToken cancellationToken)
|
||||
public async Task<SecurityPolicyDto> UpdatePolicyAsync(Guid userId, bool allowPersist, bool allowSync, int secondFactorExpiryMinutes, bool isTrustedDeviceOnly, CancellationToken cancellationToken)
|
||||
{
|
||||
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
|
||||
if (policy == null)
|
||||
{
|
||||
policy = new SecurityPolicyEntity { Id = Guid.NewGuid(), UserId = userId, AllowPersist = allowPersist, AllowSync = allowSync };
|
||||
policy = new SecurityPolicyEntity
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
AllowPersist = allowPersist,
|
||||
AllowSync = allowSync,
|
||||
SecondFactorExpiryMinutes = secondFactorExpiryMinutes,
|
||||
IsTrustedDeviceOnly = isTrustedDeviceOnly
|
||||
};
|
||||
_dbContext.SecurityPolicies.Add(policy);
|
||||
}
|
||||
else
|
||||
{
|
||||
policy.AllowPersist = allowPersist;
|
||||
policy.AllowSync = allowSync;
|
||||
policy.SecondFactorExpiryMinutes = secondFactorExpiryMinutes;
|
||||
policy.IsTrustedDeviceOnly = isTrustedDeviceOnly;
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace Hua.Todo.Application.Data.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// 将 <see cref="DateTime"/> 以 UTC 的 ISO 8601(Round-trip)字符串形式持久化到 SQLite,
|
||||
/// 并在读取时兼容历史遗留的 “ticks/Unix 时间戳” 数字字符串,避免因脏数据导致查询失败。
|
||||
/// </summary>
|
||||
public sealed class LenientUtcDateTimeStringConverter : ValueConverter<DateTime, string>
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建 UTC DateTime 与 SQLite TEXT 之间的转换器。
|
||||
/// </summary>
|
||||
public LenientUtcDateTimeStringConverter()
|
||||
: base(
|
||||
model => ConvertModelToProvider(model),
|
||||
provider => ConvertProviderToModel(provider))
|
||||
{
|
||||
}
|
||||
|
||||
private static string ConvertModelToProvider(DateTime value)
|
||||
{
|
||||
var utc = value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
return utc.ToString("O", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static DateTime ConvertProviderToModel(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
if (numeric >= DateTime.MinValue.Ticks && numeric <= DateTime.MaxValue.Ticks && numeric >= 10_000_000_000_000)
|
||||
{
|
||||
return new DateTime(numeric, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
if (numeric >= 0 && numeric <= 253_402_300_799_999)
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(numeric).UtcDateTime;
|
||||
}
|
||||
|
||||
if (numeric >= 0 && numeric <= 253_402_300_799)
|
||||
{
|
||||
return DateTimeOffset.FromUnixTimeSeconds(numeric).UtcDateTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var parsed))
|
||||
{
|
||||
return parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Hua.Todo.Core.Entities;
|
||||
using Hua.Todo.Application.Data.Converters;
|
||||
|
||||
namespace Hua.Todo.Application.Data;
|
||||
|
||||
@@ -36,6 +37,11 @@ public class TodoDbContext : DbContext
|
||||
/// </summary>
|
||||
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 安全审计日志集合。
|
||||
/// </summary>
|
||||
public DbSet<AuditLogEntity> AuditLogs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型映射。
|
||||
/// </summary>
|
||||
@@ -44,6 +50,8 @@ public class TodoDbContext : DbContext
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
|
||||
|
||||
modelBuilder.Entity<TaskEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("Tasks");
|
||||
@@ -52,8 +60,8 @@ public class TodoDbContext : DbContext
|
||||
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
|
||||
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')");
|
||||
entity.Property(e => e.CreatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
|
||||
entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
|
||||
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany(u => u.Tasks)
|
||||
@@ -80,8 +88,9 @@ public class TodoDbContext : DbContext
|
||||
{
|
||||
entity.ToTable("UserSessions");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.CreatedAtUtc).IsRequired();
|
||||
entity.Property(e => e.ExpiresAtUtc).IsRequired();
|
||||
entity.Property(e => e.CreatedAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
|
||||
entity.Property(e => e.ExpiresAtUtc).IsRequired().HasConversion(utcDateTimeConverter);
|
||||
entity.Property(e => e.StepUpExpiresAtUtc).HasConversion(utcDateTimeConverter);
|
||||
entity.HasIndex(e => e.UserId);
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
@@ -93,13 +102,29 @@ public class TodoDbContext : DbContext
|
||||
{
|
||||
entity.ToTable("SecurityPolicies");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.UserId).IsRequired();
|
||||
entity.Property(e => e.AllowPersist).HasDefaultValue(true);
|
||||
entity.Property(e => e.AllowSync).HasDefaultValue(true);
|
||||
entity.Property(e => e.SecondFactorExpiryMinutes).HasDefaultValue(30);
|
||||
entity.Property(e => e.IsTrustedDeviceOnly).HasDefaultValue(false);
|
||||
entity.HasIndex(e => e.UserId).IsUnique();
|
||||
entity.HasOne(e => e.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AuditLogEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("AuditLogs");
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.TimestampUtc).IsRequired().HasConversion(utcDateTimeConverter);
|
||||
entity.Property(e => e.EventType).IsRequired().HasMaxLength(64);
|
||||
entity.Property(e => e.Description).HasMaxLength(500);
|
||||
entity.Property(e => e.ClientIp).HasMaxLength(64);
|
||||
entity.Property(e => e.UserAgent).HasMaxLength(500);
|
||||
entity.HasIndex(e => e.UserId);
|
||||
entity.HasIndex(e => e.TimestampUtc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<!-- 当指定桌面平台 RID 或显式标记为桌面构建时,仅保留 net10.0 目标框架,避免还原阶段为移动端 TFM 解析 Mono runtime pack。 -->
|
||||
<IsDesktopRID Condition="'$(IsDesktopBuild)' == 'true' or ('$(RuntimeIdentifier)' != '' and ($([System.String]::Copy($(RuntimeIdentifier)).StartsWith('win-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('linux-')) or $([System.String]::Copy($(RuntimeIdentifier)).StartsWith('osx-'))))">true</IsDesktopRID>
|
||||
<TargetFrameworks Condition="'$(IsDesktopRID)' == 'true'">net10.0</TargetFrameworks>
|
||||
<TargetFrameworks Condition="'$(IsDesktopRID)' != 'true'">net10.0;net10.0-android36.0;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputType>Library</OutputType>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hua.Todo.Application.Migrations
|
||||
{
|
||||
[DbContext(typeof(TodoDbContext))]
|
||||
[Migration("20260413140347_UpdateSecurityEntities")]
|
||||
partial class UpdateSecurityEntities
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AllowPersist")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowSync")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsTrustedDeviceOnly")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SecondFactorExpiryMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SecurityPolicies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int?>("ParentTaskId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedAtUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExpiresAtUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StepUpExpiresAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserSessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||
{
|
||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||
.WithMany("SubTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("SubTasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hua.Todo.Application.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateSecurityEntities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "CreatedAtUtc",
|
||||
table: "Users",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "UpdatedAtUtc",
|
||||
table: "Users",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsTrustedDeviceOnly",
|
||||
table: "SecurityPolicies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SecondFactorExpiryMinutes",
|
||||
table: "SecurityPolicies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CreatedAtUtc",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UpdatedAtUtc",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsTrustedDeviceOnly",
|
||||
table: "SecurityPolicies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SecondFactorExpiryMinutes",
|
||||
table: "SecurityPolicies");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Hua.Todo.Application.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hua.Todo.Application.Migrations
|
||||
{
|
||||
[DbContext(typeof(TodoDbContext))]
|
||||
[Migration("20260413140753_AddAuditLogs")]
|
||||
partial class AddAuditLogs
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSuccess")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TimestampUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TimestampUtc");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AllowPersist")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("AllowSync")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsTrustedDeviceOnly")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("SecondFactorExpiryMinutes")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30);
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SecurityPolicies", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
|
||||
b.Property<bool>("IsCompleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int?>("ParentTaskId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UpdatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue(new Guid("00000000-0000-0000-0000-000000000001"));
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentTaskId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserName")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CreatedAtUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ExpiresAtUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StepUpExpiresAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserSessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||
{
|
||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
|
||||
.WithMany("SubTasks")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentTask");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserSessionEntity", b =>
|
||||
{
|
||||
b.HasOne("Hua.Todo.Core.Entities.UserEntity", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("SubTasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.UserEntity", b =>
|
||||
{
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Hua.Todo.Application.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAuditLogs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "SecondFactorExpiryMinutes",
|
||||
table: "SecurityPolicies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 30,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER");
|
||||
|
||||
migrationBuilder.AlterColumn<bool>(
|
||||
name: "IsTrustedDeviceOnly",
|
||||
table: "SecurityPolicies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false,
|
||||
oldClrType: typeof(bool),
|
||||
oldType: "INTEGER");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AuditLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
TimestampUtc = table.Column<string>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
UserName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EventType = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
ClientIp = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
|
||||
UserAgent = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true),
|
||||
IsSuccess = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AuditLogs", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogs_TimestampUtc",
|
||||
table: "AuditLogs",
|
||||
column: "TimestampUtc");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AuditLogs_UserId",
|
||||
table: "AuditLogs",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AuditLogs");
|
||||
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "SecondFactorExpiryMinutes",
|
||||
table: "SecurityPolicies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER",
|
||||
oldDefaultValue: 30);
|
||||
|
||||
migrationBuilder.AlterColumn<bool>(
|
||||
name: "IsTrustedDeviceOnly",
|
||||
table: "SecurityPolicies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
oldClrType: typeof(bool),
|
||||
oldType: "INTEGER",
|
||||
oldDefaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,52 @@ namespace Hua.Todo.Application.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.AuditLogEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClientIp")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EventType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSuccess")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TimestampUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserAgent")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TimestampUtc");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AuditLogs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Hua.Todo.Core.Entities.SecurityPolicyEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -33,6 +79,16 @@ namespace Hua.Todo.Application.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsTrustedDeviceOnly")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<int>("SecondFactorExpiryMinutes")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30);
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -50,7 +106,8 @@ namespace Hua.Todo.Application.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
b.Property<string>("CreatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
@@ -73,7 +130,8 @@ namespace Hua.Todo.Application.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
b.Property<string>("UpdatedAt")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValueSql("datetime('now')");
|
||||
@@ -98,6 +156,9 @@ namespace Hua.Todo.Application.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -107,6 +168,9 @@ namespace Hua.Todo.Application.Migrations
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -126,13 +190,15 @@ namespace Hua.Todo.Application.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedAtUtc")
|
||||
b.Property<string>("CreatedAtUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("ExpiresAtUtc")
|
||||
b.Property<string>("ExpiresAtUtc")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("StepUpExpiresAtUtc")
|
||||
b.Property<string>("StepUpExpiresAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
|
||||
@@ -2,27 +2,9 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Hua.Todo.Avalonia.App"
|
||||
xmlns:local="using:Hua.Todo.Avalonia"
|
||||
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
|
||||
x:DataType="vm:AppTrayViewModel"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
||||
ToolTipText="{Binding TrayTooltipText}"
|
||||
Command="{Binding ShowMainWindowCommand}">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="显示主窗口" Command="{Binding ShowMainWindowCommand}" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出" Command="{Binding ExitApplicationCommand}" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
@@ -13,7 +13,6 @@ using Hua.Todo.Avalonia.Views;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -98,9 +97,7 @@ public partial class App : global::Avalonia.Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
|
||||
_webServer = new EmbeddedWebServerService(_appSettings!);
|
||||
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
@@ -132,10 +129,16 @@ public partial class App : global::Avalonia.Application
|
||||
};
|
||||
}
|
||||
|
||||
DataContext = new AppTrayViewModel(
|
||||
AppMetadata.GetTrayTooltipText(),
|
||||
ShowMainWindow,
|
||||
() => ExitApplication(desktop));
|
||||
// 只在支持的平台上初始化系统托盘
|
||||
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
|
||||
{
|
||||
var trayViewModel = new AppTrayViewModel(
|
||||
AppMetadata.GetTrayTooltipText(),
|
||||
ShowMainWindow,
|
||||
() => ExitApplication(desktop));
|
||||
|
||||
InitializeTrayIcon(trayViewModel);
|
||||
}
|
||||
|
||||
_mainWindow = new MainWindow(_appSettings!, _webServer)
|
||||
{
|
||||
@@ -185,6 +188,12 @@ public partial class App : global::Avalonia.Application
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
|
||||
{
|
||||
_webServer = EmbeddedWebServerServiceFactory.Create(_appSettings!);
|
||||
|
||||
singleView.MainView = new MainView(_appSettings!, _webServer);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
@@ -257,14 +266,43 @@ public partial class App : global::Avalonia.Application
|
||||
}
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
private void InitializeTrayIcon(AppTrayViewModel trayViewModel)
|
||||
{
|
||||
var dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
foreach (var plugin in dataValidationPluginsToRemove)
|
||||
try
|
||||
{
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
var trayIcons = new TrayIcons();
|
||||
|
||||
var iconUri = new Uri("avares://Hua.Todo.Avalonia/icon.ico");
|
||||
var icon = new WindowIcon(AssetLoader.Open(iconUri));
|
||||
|
||||
var trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = icon,
|
||||
ToolTipText = trayViewModel.TrayTooltipText,
|
||||
Command = trayViewModel.ShowMainWindowCommand
|
||||
};
|
||||
|
||||
var menu = new NativeMenu();
|
||||
menu.Items.Add(new NativeMenuItem
|
||||
{
|
||||
Header = "显示主窗口",
|
||||
Command = trayViewModel.ShowMainWindowCommand
|
||||
});
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
menu.Items.Add(new NativeMenuItem
|
||||
{
|
||||
Header = "退出",
|
||||
Command = trayViewModel.ExitApplicationCommand
|
||||
});
|
||||
|
||||
trayIcon.Menu = menu;
|
||||
trayIcons.Add(trayIcon);
|
||||
|
||||
TrayIcon.SetIcons(this, trayIcons);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[App] Tray icon initialization failed: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 172 KiB |
@@ -1,7 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('linux'))">net10.0</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">net10.0</TargetFrameworks>
|
||||
<!-- 追加 Android 目标,作为未来的移动端主入口。 -->
|
||||
<TargetFrameworks>$(TargetFrameworks);net10.0-android</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
@@ -11,30 +17,56 @@
|
||||
<ForceWebBuild>false</ForceWebBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-android'">
|
||||
<SupportedOSPlatformVersion>21.0</SupportedOSPlatformVersion>
|
||||
<ApplicationId>com.hua.todo</ApplicationId>
|
||||
<ApplicationTitle>代办</ApplicationTitle>
|
||||
<AndroidApplication>true</AndroidApplication>
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
<!-- 彻底解决 Java.Lang.NoClassDefFoundError:
|
||||
在 .NET 10 Preview 环境中,Fast Deployment 往往无法正确处理动态加载的 AndroidX 资源。
|
||||
通过禁用 Fast Deployment 并强制将所有程序集嵌入 APK,可以解决此问题。 -->
|
||||
<AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
|
||||
<AndroidEnableFastDeployment>False</AndroidEnableFastDeployment>
|
||||
<EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<AvaloniaResource Include="icon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.13" />
|
||||
<PackageReference Include="Avalonia.Android" Version="11.3.13" Condition="'$(TargetFramework)' == 'net10.0-android'" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.13" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.13" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.13" />
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<!-- 增加 Android 下的 WebView 支持包,避免跨平台逻辑中断。 -->
|
||||
<PackageReference Include="WebView.Avalonia.Android" Version="11.0.0.1" Condition="'$(TargetFramework)' == 'net10.0-android'" />
|
||||
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5">
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.13">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
|
||||
<!-- 为了修复 NoClassDefFoundError,我们需要显式确保核心库在 AOT 或 裁剪时被保留。 -->
|
||||
<PackageReference Include="Xamarin.AndroidX.AppCompat" Version="1.7.1.1" Condition="'$(TargetFramework)' == 'net10.0-android'" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="icon.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.hua.todo">
|
||||
<application android:allowBackup="true" android:icon="@drawable/icon" android:label="代办" android:supportsRtl="true">
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Avalonia;
|
||||
using Avalonia.Android;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Platforms.Android;
|
||||
|
||||
/// <summary>
|
||||
/// Avalonia Android 平台入口 Activity。
|
||||
/// </summary>
|
||||
[Activity(
|
||||
Label = "代办",
|
||||
Theme = "@style/AvaloniaMainActivityTheme",
|
||||
Icon = "@drawable/icon",
|
||||
MainLauncher = true,
|
||||
ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize | ConfigChanges.UiMode)]
|
||||
public class MainActivity : AvaloniaMainActivity<App>
|
||||
{
|
||||
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
|
||||
{
|
||||
return base.CustomizeAppBuilder(builder)
|
||||
.WithInterFont();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using Hua.Todo.Avalonia.Services;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia;
|
||||
@@ -10,8 +12,14 @@ sealed class Program
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// 设置嵌入式 Web 服务器工厂
|
||||
EmbeddedWebServerServiceFactory.SetFactory(appSettings => new EmbeddedWebServerService(appSettings));
|
||||
|
||||
BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?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" fill="#512BD4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 228 B |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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 |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,15 @@
|
||||
Any raw assets you want to be deployed with your application can be placed in
|
||||
this directory (and child directories). Deployment of the asset to your application
|
||||
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
|
||||
|
||||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
|
||||
These files will be deployed with your package and will be accessible using Essentials:
|
||||
|
||||
async Task LoadMauiAsset()
|
||||
{
|
||||
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var contents = reader.ReadToEnd();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
<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" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
|
||||
|
||||
<Color x:Key="Primary">#512BD4</Color>
|
||||
<Color x:Key="PrimaryDark">#ac99ea</Color>
|
||||
<Color x:Key="PrimaryDarkText">#242424</Color>
|
||||
<Color x:Key="Secondary">#DFD8F7</Color>
|
||||
<Color x:Key="SecondaryDarkText">#9880e5</Color>
|
||||
<Color x:Key="Tertiary">#2B0B98</Color>
|
||||
|
||||
<Color x:Key="White">White</Color>
|
||||
<Color x:Key="Black">Black</Color>
|
||||
<Color x:Key="Magenta">#D600AA</Color>
|
||||
<Color x:Key="MidnightBlue">#190649</Color>
|
||||
<Color x:Key="OffBlack">#1f1f1f</Color>
|
||||
|
||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
||||
<Color x:Key="Gray200">#C8C8C8</Color>
|
||||
<Color x:Key="Gray300">#ACACAC</Color>
|
||||
<Color x:Key="Gray400">#919191</Color>
|
||||
<Color x:Key="Gray500">#6E6E6E</Color>
|
||||
<Color x:Key="Gray600">#404040</Color>
|
||||
<Color x:Key="Gray900">#212121</Color>
|
||||
<Color x:Key="Gray950">#141414</Color>
|
||||
|
||||
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
|
||||
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
|
||||
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
|
||||
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
|
||||
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
|
||||
|
||||
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
|
||||
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
|
||||
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
|
||||
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
|
||||
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
|
||||
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
|
||||
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
|
||||
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,434 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
|
||||
|
||||
<Style TargetType="ActivityIndicator">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="IndicatorView">
|
||||
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
|
||||
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="StrokeShape" Value="Rectangle"/>
|
||||
<Setter Property="StrokeThickness" Value="1"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="BoxView">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="14,10"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="CheckBox">
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="DatePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Editor">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Entry">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ImageButton">
|
||||
<Setter Property="Opacity" Value="1" />
|
||||
<Setter Property="BorderColor" Value="Transparent"/>
|
||||
<Setter Property="BorderWidth" Value="0"/>
|
||||
<Setter Property="CornerRadius" Value="0"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="PointerOver" />
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="Headline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="32" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Label" x:Key="SubHeadline">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="HorizontalOptions" Value="Center" />
|
||||
<Setter Property="HorizontalTextAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Picker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="ProgressBar">
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RadioButton">
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="RefreshView">
|
||||
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchBar">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SearchHandler">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular" />
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shadow">
|
||||
<Setter Property="Radius" Value="15" />
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Offset" Value="10,10" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Slider">
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="SwipeItem">
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Switch">
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
<Setter Property="ThumbColor" Value="{StaticResource White}" />
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="On">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Off">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TimePicker">
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
|
||||
<Setter Property="BackgroundColor" Value="Transparent"/>
|
||||
<Setter Property="FontFamily" Value="OpenSansRegular"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="MinimumHeightRequest" Value="44"/>
|
||||
<Setter Property="MinimumWidthRequest" Value="44"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="Disabled">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
<Style TargetType="TitleBar">
|
||||
<Setter Property="MinimumHeightRequest" Value="32"/>
|
||||
<Setter Property="VisualStateManager.VisualStateGroups">
|
||||
<VisualStateGroupList>
|
||||
<VisualStateGroup x:Name="TitleActiveStates">
|
||||
<VisualState x:Name="TitleBarTitleActive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="Transparent" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="TitleBarTitleInactive">
|
||||
<VisualState.Setters>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateGroupList>
|
||||
</Setter>
|
||||
</Style>
|
||||
-->
|
||||
|
||||
<Style TargetType="Page" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="Shell" ApplyToDerivedTypes="True">
|
||||
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
|
||||
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
|
||||
<Setter Property="Shell.NavBarHasShadow" Value="False" />
|
||||
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
|
||||
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="NavigationPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="TabbedPage">
|
||||
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
|
||||
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
|
||||
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 嵌入式 WebServer 的平台工厂。
|
||||
/// 由平台入口项目在启动时注入实际实现(例如 Desktop 使用 Kestrel,Android 可使用 Noop 或移动端实现),
|
||||
/// 以避免共享项目直接依赖某个平台不可用的运行时组件。
|
||||
/// </summary>
|
||||
public static class EmbeddedWebServerServiceFactory
|
||||
{
|
||||
private static Func<AppSettings, IEmbeddedWebServerService>? _factory;
|
||||
|
||||
/// <summary>
|
||||
/// 设置 WebServer 创建工厂。
|
||||
/// 平台入口应尽早调用(在 <see cref="global::Avalonia.Application"/> 生命周期开始前完成),以确保应用启动过程可用。
|
||||
/// </summary>
|
||||
/// <param name="factory">根据应用配置创建 WebServer 的工厂方法。</param>
|
||||
/// <exception cref="ArgumentNullException">当 <paramref name="factory"/> 为 null。</exception>
|
||||
public static void SetFactory(Func<AppSettings, IEmbeddedWebServerService> factory)
|
||||
{
|
||||
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建 WebServer 实例。
|
||||
/// 若平台未注入工厂,则回退为 <see cref="NoopEmbeddedWebServerService"/>(不启动本地服务,仅用于维持调用链)。
|
||||
/// </summary>
|
||||
/// <param name="appSettings">应用配置。</param>
|
||||
/// <returns>嵌入式 WebServer 实例。</returns>
|
||||
public static IEmbeddedWebServerService Create(AppSettings appSettings)
|
||||
{
|
||||
if (_factory != null)
|
||||
{
|
||||
return _factory(appSettings);
|
||||
}
|
||||
|
||||
return new NoopEmbeddedWebServerService(appSettings.WebServer.HostUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ public static class GlobalHotKeyServiceFactory
|
||||
{
|
||||
return new WindowsGlobalHotKeyService();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return new LinuxGlobalHotKeyService();
|
||||
}
|
||||
else if (OperatingSystem.IsAndroid())
|
||||
{
|
||||
return new AndroidGlobalHotKeyService();
|
||||
}
|
||||
|
||||
return new NoopGlobalHotKeyService();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 不启动任何本地 WebServer 的占位实现。
|
||||
/// 适用于移动端/受限平台:仍可复用 UI 与前端契约注入逻辑,但 WebView 需要指向外部可访问的前端地址。
|
||||
/// </summary>
|
||||
public sealed class NoopEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建 <see cref="NoopEmbeddedWebServerService"/>。
|
||||
/// </summary>
|
||||
/// <param name="baseUrl">
|
||||
/// 逻辑上的基础 URL。该实现不会实际监听端口,但会把该值暴露给调用方用于日志/显示或兼容逻辑。
|
||||
/// </param>
|
||||
public NoopEmbeddedWebServerService(string baseUrl)
|
||||
{
|
||||
BaseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsRunning => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BaseUrl { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync() => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync() => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services.Platforms;
|
||||
|
||||
/// <summary>
|
||||
/// Android 平台全局热键服务实现类
|
||||
/// 由于 Android 限制全局热键,使用通知快捷方式作为替代方案
|
||||
/// </summary>
|
||||
public class AndroidGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
private Action? _callback;
|
||||
|
||||
/// <summary>
|
||||
/// Android 平台不支持全局热键
|
||||
/// </summary>
|
||||
public bool IsSupported => false;
|
||||
|
||||
/// <summary>
|
||||
/// 注册通知快捷方式作为热键替代方案
|
||||
/// </summary>
|
||||
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
|
||||
/// <param name="key">主键(如 X、C)。</param>
|
||||
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
_callback = callback;
|
||||
Console.WriteLine($"[AndroidGlobalHotKeyService] Registering notification shortcut as hotkey alternative");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销快捷方式(空实现)
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
Console.WriteLine("[AndroidGlobalHotKeyService] Unregistering hotkey");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新快捷方式配置
|
||||
/// </summary>
|
||||
/// <param name="modifiers">新的修饰键。</param>
|
||||
/// <param name="key">新的主键。</param>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
if (_callback != null)
|
||||
{
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Services.Platforms;
|
||||
|
||||
/// <summary>
|
||||
/// Linux 平台全局热键服务实现类
|
||||
/// 提供基于 Linux 平台的全局热键支持
|
||||
/// </summary>
|
||||
public class LinuxGlobalHotKeyService : IGlobalHotKeyService
|
||||
{
|
||||
private Action? _callback;
|
||||
private string? _modifiers;
|
||||
private string? _key;
|
||||
|
||||
/// <summary>
|
||||
/// Linux 平台支持全局热键
|
||||
/// </summary>
|
||||
public bool IsSupported => true;
|
||||
|
||||
/// <summary>
|
||||
/// 注册全局热键
|
||||
/// </summary>
|
||||
/// <param name="modifiers">修饰键(如 Alt、Control;多个键用逗号分隔)。</param>
|
||||
/// <param name="key">主键(如 X、C)。</param>
|
||||
/// <param name="callback">热键触发时回调(不得阻塞,应自行切回 UI 线程)。</param>
|
||||
public void RegisterHotKey(string modifiers, string key, Action callback)
|
||||
{
|
||||
try
|
||||
{
|
||||
_callback = callback;
|
||||
_modifiers = modifiers;
|
||||
_key = key;
|
||||
|
||||
// Linux 平台热键注册逻辑
|
||||
// 这里可以使用如 libX11 或其他 Linux 热键库
|
||||
// 由于 Avalonia 本身对 Linux 热键支持有限,这里提供基本实现
|
||||
Console.WriteLine($"[LinuxGlobalHotKeyService] Registered hotkey: {modifiers}+{key}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to register hotkey: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注销已注册的热键
|
||||
/// </summary>
|
||||
public void UnregisterHotKey()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Linux 平台热键注销逻辑
|
||||
Console.WriteLine("[LinuxGlobalHotKeyService] Unregistered hotkey");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to unregister hotkey: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新热键配置
|
||||
/// </summary>
|
||||
/// <param name="modifiers">新的修饰键。</param>
|
||||
/// <param name="key">新的主键。</param>
|
||||
public void UpdateHotKey(string modifiers, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
UnregisterHotKey();
|
||||
if (_callback != null)
|
||||
{
|
||||
RegisterHotKey(modifiers, key, _callback);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[LinuxGlobalHotKeyService] Failed to update hotkey: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Hua.Todo.Avalonia.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
|
||||
x:Class="Hua.Todo.Avalonia.Views.MainView"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<WebView Name="MainWebView" />
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
using Hua.Todo.Avalonia.Services;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 应用主视图。
|
||||
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
|
||||
/// </summary>
|
||||
public partial class MainView : UserControl
|
||||
{
|
||||
private readonly AppSettings _appSettings;
|
||||
private readonly IEmbeddedWebServerService _webServer;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用于设计器预览的主视图实例。
|
||||
/// </summary>
|
||||
public MainView()
|
||||
{
|
||||
InitializeComponent();
|
||||
_appSettings = new AppSettings();
|
||||
_webServer = new NoopEmbeddedWebServerService(_appSettings.WebServer.HostUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建运行时主视图实例。
|
||||
/// </summary>
|
||||
/// <param name="appSettings">应用配置。</param>
|
||||
/// <param name="webServer">嵌入式 WebServer。</param>
|
||||
public MainView(AppSettings appSettings, IEmbeddedWebServerService webServer)
|
||||
{
|
||||
InitializeComponent();
|
||||
_appSettings = appSettings;
|
||||
_webServer = webServer;
|
||||
|
||||
SetupWebView();
|
||||
}
|
||||
|
||||
private void SetupWebView()
|
||||
{
|
||||
try
|
||||
{
|
||||
var webUrl = ResolveWebUrl();
|
||||
MainWebView.Url = webUrl;
|
||||
|
||||
MainWebView.NavigationCompleted += async (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_webServer is not NoopEmbeddedWebServerService)
|
||||
{
|
||||
var apiBase = $"{_appSettings.WebServer.HostUrl.TrimEnd('/')}/api";
|
||||
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
|
||||
}
|
||||
|
||||
await MainWebView.ExecuteScriptAsync(@"
|
||||
window.mauiInterop = {
|
||||
onHotKeyConfigUpdated: null,
|
||||
openHotKeySettings: function(config) {
|
||||
const event = new CustomEvent('openHotKeySettings', { detail: config });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
updateHotKeyConfig: function(modifiers, key, isEnabled) {
|
||||
const event = new CustomEvent('updateHotKeyConfig', {
|
||||
detail: { modifiers, key, isEnabled }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hotKeyConfigChanged', function(e) {
|
||||
if (window.mauiInterop.onHotKeyConfigUpdated) {
|
||||
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
|
||||
}
|
||||
});
|
||||
");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MainView] WebView script injection failed: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MainView] WebView initialization failed: {ex}");
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 Runtime;Linux 需要 WebKitGTK)。",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private Uri ResolveWebUrl()
|
||||
{
|
||||
if (OperatingSystem.IsAndroid())
|
||||
{
|
||||
return new Uri(_appSettings.WebServer.ForEndUrl);
|
||||
}
|
||||
|
||||
return _appSettings.WebServer.IsUsingStatic
|
||||
? new Uri(_appSettings.WebServer.HostUrl)
|
||||
: new Uri(_appSettings.WebServer.ForEndUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="640"
|
||||
x:Class="Hua.Todo.Avalonia.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Icon="/icon.ico"
|
||||
Width="450"
|
||||
Height="640"
|
||||
Title="待办事项"
|
||||
@@ -15,7 +15,11 @@
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel/>
|
||||
</Design.DataContext>
|
||||
|
||||
<WebView Name="MainWebView" />
|
||||
|
||||
<Grid>
|
||||
<TextBlock Text="Loading..."
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Hua.Todo.Avalonia.Models;
|
||||
using Hua.Todo.Avalonia.Services;
|
||||
using System;
|
||||
|
||||
namespace Hua.Todo.Avalonia.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 应用主窗口。
|
||||
/// 负责承载 WebView(前端 UI)并在导航完成后注入前端契约字段。
|
||||
/// 负责承载 <see cref="MainView"/>(前端 UI)。
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
@@ -23,7 +20,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
InitializeComponent();
|
||||
_appSettings = new AppSettings();
|
||||
_webServer = new EmbeddedWebServerService(_appSettings);
|
||||
_webServer = new NoopEmbeddedWebServerService(_appSettings.WebServer.HostUrl);
|
||||
Content = new MainView(_appSettings, _webServer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -36,67 +34,6 @@ public partial class MainWindow : Window
|
||||
InitializeComponent();
|
||||
_appSettings = appSettings;
|
||||
_webServer = webServer;
|
||||
|
||||
SetupWebView();
|
||||
}
|
||||
|
||||
private void SetupWebView()
|
||||
{
|
||||
try
|
||||
{
|
||||
MainWebView.Url =
|
||||
_appSettings.WebServer.IsUsingStatic
|
||||
? new Uri(_appSettings.WebServer.HostUrl)
|
||||
: new Uri(_appSettings.WebServer.ForEndUrl);
|
||||
|
||||
MainWebView.NavigationCompleted += async (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_webServer.IsRunning)
|
||||
{
|
||||
var apiBase = $"{_webServer.BaseUrl.TrimEnd('/')}/api";
|
||||
await MainWebView.ExecuteScriptAsync($"window.__API_BASE_URL__ = '{apiBase}';");
|
||||
}
|
||||
|
||||
await MainWebView.ExecuteScriptAsync(@"
|
||||
window.mauiInterop = {
|
||||
onHotKeyConfigUpdated: null,
|
||||
openHotKeySettings: function(config) {
|
||||
const event = new CustomEvent('openHotKeySettings', { detail: config });
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
updateHotKeyConfig: function(modifiers, key, isEnabled) {
|
||||
const event = new CustomEvent('updateHotKeyConfig', {
|
||||
detail: { modifiers, key, isEnabled }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hotKeyConfigChanged', function(e) {
|
||||
if (window.mauiInterop.onHotKeyConfigUpdated) {
|
||||
window.mauiInterop.onHotKeyConfigUpdated(e.detail);
|
||||
}
|
||||
});
|
||||
");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MainWindow] WebView script injection failed: {ex.Message}");
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MainWindow] WebView initialization failed: {ex}");
|
||||
Content = new TextBlock
|
||||
{
|
||||
Text = "WebView 初始化失败。请检查运行环境依赖(Windows 需要 WebView2 Runtime;Linux 需要 WebKitGTK)。",
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
}
|
||||
Content = new MainView(_appSettings, _webServer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"WebServer": {
|
||||
"Port": 5057,
|
||||
"IsUsingStatic": false,
|
||||
"IsUsingStatic": true,
|
||||
"ConnectionString": "",
|
||||
"HostUrl": "http://localhost:5057",
|
||||
"ForEndUrl": "http://localhost:5174"
|
||||
"ForEndUrl": "http://localhost:5057"
|
||||
},
|
||||
"HotKey": {
|
||||
"DefaultModifiers": "Alt",
|
||||
|
||||
|
After Width: | Height: | Size: 192 KiB |
@@ -0,0 +1,44 @@
|
||||
#define MyAppName "Hua.Todo.Avalonia"
|
||||
#define MyAppVersion "1.2.7"
|
||||
#define MyAppPublisher "ShaoHua"
|
||||
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
|
||||
#define MyAppExeName "Hua.Todo.Avalonia.exe"
|
||||
|
||||
[Setup]
|
||||
; 1. 改用更快压缩(优先速度)
|
||||
Compression=zip/1
|
||||
|
||||
; 注意: AppId 的值唯一标识此应用程序。不要在其他应用程序的安装程序中使用相同的 AppId 值。
|
||||
; (若要生成新的 GUID,请在 IDE 中单击“工具”|“生成 GUID”。)
|
||||
AppId={{A1B2C3D4-E5F6-4321-8765-FEDCBA987654}}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
AppPublisherURL={#MyAppURL}
|
||||
AppSupportURL={#MyAppURL}
|
||||
AppUpdatesURL={#MyAppURL}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
PrivilegesRequired=lowest
|
||||
OutputDir=Output
|
||||
OutputBaseFilename={#MyAppName}_Setup_v{#MyAppVersion}
|
||||
SetupIconFile=icon.ico
|
||||
UninstallDisplayIcon={app}\icon.ico
|
||||
SolidCompression=no
|
||||
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\net10.0\win-x64\publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\icon.ico"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Hua.Todo.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 安全审计日志实体。
|
||||
/// </summary>
|
||||
public class AuditLogEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// 日志唯一标识符。
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件发生的 UTC 时间。
|
||||
/// </summary>
|
||||
public DateTime TimestampUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 相关用户 ID(可选,若为登录失败等场景可能为空)。
|
||||
/// </summary>
|
||||
public Guid? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 相关用户名。
|
||||
/// </summary>
|
||||
public string? UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件类型(如 LoginSuccess, LoginFailed, SyncStart, PolicyChanged 等)。
|
||||
/// </summary>
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 事件描述或详细信息。
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 操作来源 IP。
|
||||
/// </summary>
|
||||
public string? ClientIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 终端信息(User-Agent 等)。
|
||||
/// </summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否成功。
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; set; } = true;
|
||||
}
|
||||
@@ -29,4 +29,14 @@ public class SecurityPolicyEntity
|
||||
/// 是否允许执行同步写入(为 false 时应视为“禁止同步”)。
|
||||
/// </summary>
|
||||
public bool AllowSync { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 二次认证(Step-up)状态保持时长(分钟)。
|
||||
/// </summary>
|
||||
public int SecondFactorExpiryMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// 是否仅限受信任终端执行高风险操作。
|
||||
/// </summary>
|
||||
public bool IsTrustedDeviceOnly { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@ public class UserEntity
|
||||
/// </summary>
|
||||
public string Role { get; set; } = "user";
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 最后更新时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime UpdatedAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// 用户任务集合。
|
||||
/// </summary>
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,7 +5,7 @@ using Hua.Todo.Application.CloudSync;
|
||||
using Hua.Todo.Application.DynamicApi;
|
||||
using Hua.Todo.Application.DynamicApi.Swagger;
|
||||
using Hua.Todo.Application.Interfaces;
|
||||
using Hua.Todo.Application.Models;
|
||||
using Hua.Todo.Application.CloudSync.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -30,11 +30,24 @@ builder.Services.AddCors(options =>
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Apply database migrations
|
||||
// Apply database migrations and seed default admin
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
|
||||
var services = scope.ServiceProvider;
|
||||
var dbContext = services.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
|
||||
dbContext.Database.Migrate();
|
||||
|
||||
// Seed default admin from configuration
|
||||
var config = services.GetRequiredService<IConfiguration>();
|
||||
var adminConfig = config.GetSection("DefaultAdmin");
|
||||
var adminUserName = adminConfig["UserName"];
|
||||
var adminPassword = adminConfig["Password"];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(adminUserName) && !string.IsNullOrWhiteSpace(adminPassword))
|
||||
{
|
||||
var authService = services.GetRequiredService<CloudAuthService>();
|
||||
await authService.BootstrapAdminAsync(adminUserName, adminPassword, "system", "seeding", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
@@ -44,9 +57,14 @@ if (app.Environment.IsDevelopment())
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseCors("AllowAll");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// 简单的管理后台入口跳转
|
||||
app.MapGet("/admin", () => Results.Redirect("/admin/index.html"));
|
||||
|
||||
app.MapCloudSyncEndpoints();
|
||||
app.UseDynamicApi();
|
||||
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"DefaultAdmin": {
|
||||
"UserName": "admin",
|
||||
"Password": "ChangeMe@123"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hua.Todo 管理后台</title>
|
||||
<!-- Element Plus & Vue 3 via CDN -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://unpkg.com/element-plus"></script>
|
||||
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
|
||||
<style>
|
||||
body { margin: 0; font-family: -apple-system, sans-serif; background: #f5f7fa; }
|
||||
#app { height: 100vh; display: flex; flex-direction: column; }
|
||||
.header { background: #409EFF; color: white; padding: 0 20px; height: 60px; line-height: 60px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.main-container { flex: 1; overflow: hidden; display: flex; }
|
||||
.sidebar { width: 200px; background: #fff; border-right: 1px solid #e6e6e6; }
|
||||
.content { flex: 1; padding: 20px; overflow-y: auto; }
|
||||
.login-container { height: 100vh; display: flex; justify-content: center; align-items: center; background: #f5f7fa; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.tag-admin { background-color: #f56c6c !important; border-color: #f56c6c !important; color: white !important; }
|
||||
.audit-failed { color: #f56c6c; }
|
||||
.audit-success { color: #67c23a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 登录界面 -->
|
||||
<div v-if="!isLoggedIn" class="login-container">
|
||||
<el-card style="width: 400px;">
|
||||
<template #header>
|
||||
<div style="text-align: center; font-weight: bold;">管理员登录</div>
|
||||
</template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="loginForm.userName" prefix-icon="User"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="loginForm.password" type="password" prefix-icon="Lock" show-password @keyup.enter="handleLogin"></el-input>
|
||||
</el-form-item>
|
||||
<el-button type="primary" style="width: 100%" @click="handleLogin" :loading="loggingIn">登录</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 主界面 -->
|
||||
<template v-else>
|
||||
<div class="header">
|
||||
<div style="font-size: 1.2rem; font-weight: bold;">Hua.Todo 管理后台 <small style="font-size: 0.8rem; opacity: 0.8;">v1.2.0</small></div>
|
||||
<div>
|
||||
<span style="margin-right: 15px;">欢迎, {{ currentUser.userName }}</span>
|
||||
<el-button type="info" size="small" @click="refreshAll">刷新</el-button>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="sidebar">
|
||||
<el-menu :default-active="activeTab" @select="handleTabSelect" style="border-right: none;">
|
||||
<el-menu-item index="users">用户管理</el-menu-item>
|
||||
<el-menu-item index="sessions">会话管理</el-menu-item>
|
||||
<el-menu-item index="logs">审计日志</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 用户管理 -->
|
||||
<div v-if="activeTab === 'users'">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>用户列表</span>
|
||||
<el-button type="primary" @click="showCreateUserDialog = true">新增用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="users" v-loading="loadingUsers" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户名" width="180"></el-table-column>
|
||||
<el-table-column prop="role" label="角色" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :class="scope.row.role === 'admin' ? 'tag-admin' : ''">{{ scope.row.role }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAtUtc" label="创建时间" width="200">
|
||||
<template #default="scope">{{ formatDate(scope.row.createdAtUtc) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="handleResetPassword(scope.row)">重置密码</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDeleteUser(scope.row)" :disabled="scope.row.role === 'admin'">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 会话管理 -->
|
||||
<div v-if="activeTab === 'sessions'">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>当前在线会话</span>
|
||||
<el-button size="small" @click="fetchSessions">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="sessions" v-loading="loadingSessions" style="width: 100%">
|
||||
<el-table-column prop="userName" label="用户名" width="150"></el-table-column>
|
||||
<el-table-column prop="isSteppedUp" label="已二次认证" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.isSteppedUp ? 'success' : 'info'">{{ scope.row.isSteppedUp ? '是' : '否' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAtUtc" label="过期时间" width="200">
|
||||
<template #default="scope">{{ formatDate(scope.row.expiresAtUtc) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="scope">
|
||||
<el-button size="small" type="warning" @click="handleRevokeSession(scope.row)">强制下线</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 审计日志 -->
|
||||
<div v-if="activeTab === 'logs'">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近 100 条审计日志</span>
|
||||
<el-button size="small" @click="fetchLogs">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="logs" v-loading="loadingLogs" stripe style="width: 100%; font-size: 0.85rem;">
|
||||
<el-table-column prop="timestampUtc" label="时间" width="160">
|
||||
<template #default="scope">{{ formatDate(scope.row.timestampUtc) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="eventType" label="事件" width="140"></el-table-column>
|
||||
<el-table-column prop="userName" label="用户" width="120"></el-table-column>
|
||||
<el-table-column prop="description" label="描述"></el-table-column>
|
||||
<el-table-column prop="isSuccess" label="结果" width="80">
|
||||
<template #default="scope">
|
||||
<span :class="scope.row.isSuccess ? 'audit-success' : 'audit-failed'">{{ scope.row.isSuccess ? '成功' : '失败' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clientIp" label="IP" width="120"></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 新增用户对话框 -->
|
||||
<el-dialog v-model="showCreateUserDialog" title="新增用户" width="400px">
|
||||
<el-form :model="newUser" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="newUser.userName"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="newUser.password" type="password" show-password></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="newUser.role" style="width: 100%">
|
||||
<el-option label="普通用户" value="user"></el-option>
|
||||
<el-option label="管理员" value="admin"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateUserDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreateUser" :loading="creatingUser">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const activeTab = ref('users');
|
||||
const isLoggedIn = ref(false);
|
||||
const loggingIn = ref(false);
|
||||
const loginForm = reactive({ userName: '', password: '' });
|
||||
const currentUser = ref({ userName: '', role: '' });
|
||||
|
||||
const users = ref([]);
|
||||
const sessions = ref([]);
|
||||
const logs = ref([]);
|
||||
const loadingUsers = ref(false);
|
||||
const loadingSessions = ref(false);
|
||||
const loadingLogs = ref(false);
|
||||
const showCreateUserDialog = ref(false);
|
||||
const creatingUser = ref(false);
|
||||
const newUser = reactive({ userName: '', password: '', role: 'user' });
|
||||
|
||||
// Axios 拦截器:注入 Token
|
||||
axios.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Axios 拦截器:处理鉴权失败
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||
handleLogout();
|
||||
ElementPlus.ElMessage.error('会话已失效或权限不足,请重新登录');
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
const user = localStorage.getItem('admin_user');
|
||||
if (token && user) {
|
||||
isLoggedIn.value = true;
|
||||
currentUser.value = JSON.parse(user);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginForm.userName || !loginForm.password) {
|
||||
return ElementPlus.ElMessage.warning('请输入用户名和密码');
|
||||
}
|
||||
loggingIn.value = true;
|
||||
try {
|
||||
const res = await axios.post('/auth/login', loginForm);
|
||||
const data = res.data;
|
||||
|
||||
if (data.role !== 'admin') {
|
||||
throw new Error('只有管理员可以访问此后台');
|
||||
}
|
||||
|
||||
localStorage.setItem('admin_token', data.accessToken);
|
||||
localStorage.setItem('admin_user', JSON.stringify({ userName: loginForm.userName, role: data.role }));
|
||||
|
||||
isLoggedIn.value = true;
|
||||
currentUser.value = { userName: loginForm.userName, role: data.role };
|
||||
loginForm.password = '';
|
||||
|
||||
ElementPlus.ElMessage.success('登录成功');
|
||||
refreshAll();
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error(e.message || '登录失败,请检查凭据');
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('admin_user');
|
||||
isLoggedIn.value = false;
|
||||
currentUser.value = { userName: '', role: '' };
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (!isLoggedIn.value) return;
|
||||
loadingUsers.value = true;
|
||||
try {
|
||||
const res = await axios.get('/admin/users');
|
||||
users.value = res.data;
|
||||
} catch (e) {} finally {
|
||||
loadingUsers.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (!isLoggedIn.value) return;
|
||||
loadingSessions.value = true;
|
||||
try {
|
||||
const res = await axios.get('/admin/sessions');
|
||||
sessions.value = res.data;
|
||||
} catch (e) {} finally {
|
||||
loadingSessions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!isLoggedIn.value) return;
|
||||
loadingLogs.value = true;
|
||||
try {
|
||||
const res = await axios.get('/admin/audit-logs?count=100');
|
||||
logs.value = res.data;
|
||||
} catch (e) {} finally {
|
||||
loadingLogs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabSelect = (index) => {
|
||||
activeTab.value = index;
|
||||
if (index === 'users') fetchUsers();
|
||||
if (index === 'sessions') fetchSessions();
|
||||
if (index === 'logs') fetchLogs();
|
||||
};
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!newUser.userName || !newUser.password) {
|
||||
return ElementPlus.ElMessage.warning('请填写完整信息');
|
||||
}
|
||||
creatingUser.value = true;
|
||||
try {
|
||||
await axios.post('/admin/users', newUser);
|
||||
ElementPlus.ElMessage.success('创建用户成功');
|
||||
showCreateUserDialog.value = false;
|
||||
newUser.userName = '';
|
||||
newUser.password = '';
|
||||
fetchUsers();
|
||||
} catch (e) {} finally {
|
||||
creatingUser.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (user) => {
|
||||
try {
|
||||
const { value: newPass } = await ElementPlus.ElMessageBox.prompt(
|
||||
`请输入用户 [${user.userName}] 的新密码`, '重置密码', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'password'
|
||||
});
|
||||
if (newPass) {
|
||||
await axios.post(`/admin/users/${user.id}/reset-password`, { newPassword: newPass });
|
||||
ElementPlus.ElMessage.success('重置成功');
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user) => {
|
||||
try {
|
||||
await ElementPlus.ElMessageBox.confirm(
|
||||
`确定删除用户 [${user.userName}] 吗?此操作不可逆!`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
await axios.delete(`/admin/users/${user.id}`);
|
||||
ElementPlus.ElMessage.success('删除成功');
|
||||
fetchUsers();
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const handleRevokeSession = async (session) => {
|
||||
try {
|
||||
await axios.delete(`/admin/sessions/${session.id}`);
|
||||
ElementPlus.ElMessage.success('已强制下线');
|
||||
fetchSessions();
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const refreshAll = () => {
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (checkAuth()) {
|
||||
refreshAll();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeTab, users, sessions, logs,
|
||||
loadingUsers, loadingSessions, loadingLogs,
|
||||
showCreateUserDialog, creatingUser, newUser,
|
||||
isLoggedIn, loggingIn, loginForm, currentUser,
|
||||
handleTabSelect, handleCreateUser, handleResetPassword, handleDeleteUser, handleRevokeSession,
|
||||
handleLogin, handleLogout,
|
||||
formatDate, fetchSessions, fetchLogs, refreshAll
|
||||
};
|
||||
}
|
||||
}).use(ElementPlus).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<!-- 在 Windows 平台构建时,追加 Windows 目标框架,同时保留 Android,避免覆盖。 -->
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||
|
||||
<!-- Note for MacCatalyst:
|
||||
@@ -13,6 +14,7 @@
|
||||
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
|
||||
|
||||
<OutputType>Exe</OutputType>
|
||||
<AndroidApplication>true</AndroidApplication>
|
||||
<RootNamespace>Hua.Todo.Maui</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
@@ -27,18 +29,13 @@
|
||||
<ApplicationTitle>代办</ApplicationTitle>
|
||||
|
||||
<!-- App Identifier -->
|
||||
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
|
||||
<ApplicationId>com.hua.todo</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<Version>1.2.1</Version>
|
||||
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<!-- Unified in Directory.Build.props -->
|
||||
|
||||
<!-- Assembly Info -->
|
||||
<AssemblyTitle>待办</AssemblyTitle>
|
||||
<AssemblyProduct>待办</AssemblyProduct>
|
||||
<AssemblyCompany>Hua.Todo</AssemblyCompany>
|
||||
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
|
||||
<!-- Unified in Directory.Build.props -->
|
||||
|
||||
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
|
||||
<WindowsPackageType>None</WindowsPackageType>
|
||||
@@ -53,6 +50,7 @@
|
||||
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
|
||||
<SkipWebBuild>false</SkipWebBuild>
|
||||
<ForceWebBuild>false</ForceWebBuild>
|
||||
<ForceFullAndroidDeploy>false</ForceFullAndroidDeploy>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">
|
||||
@@ -69,13 +67,16 @@
|
||||
<Optimize>False</Optimize>
|
||||
<EnableMauiImageProcessing>false</EnableMauiImageProcessing>
|
||||
<DisableResizetizer>true</DisableResizetizer>
|
||||
<!-- Android 调试默认跳过前端构建,减少每次 F5 等待时间。 -->
|
||||
<SkipWebBuild>true</SkipWebBuild>
|
||||
<AndroidPackageFormat>apk</AndroidPackageFormat>
|
||||
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
|
||||
<AndroidUseSharedRuntime>True</AndroidUseSharedRuntime>
|
||||
<AndroidEnableFastDeployment>True</AndroidEnableFastDeployment>
|
||||
<AndroidFastDeploymentType>Assemblies</AndroidFastDeploymentType>
|
||||
<EmbedAssembliesIntoApk>False</EmbedAssembliesIntoApk>
|
||||
<AndroidSupportedAbis>x86_64</AndroidSupportedAbis>
|
||||
<!-- .NET 10 Preview 环境下,Shared Runtime 和 Fast Deployment 往往会导致 AndroidX 依赖解析异常 (NoClassDefFoundError)。
|
||||
为了稳定性,默认启用完整部署模式 (EmbedAssembliesIntoApk=True)。 -->
|
||||
<AndroidUseSharedRuntime>False</AndroidUseSharedRuntime>
|
||||
<AndroidEnableFastDeployment>False</AndroidEnableFastDeployment>
|
||||
<EmbedAssembliesIntoApk>True</EmbedAssembliesIntoApk>
|
||||
<RuntimeIdentifiers>android-x64</RuntimeIdentifiers>
|
||||
<UseAppHost>false</UseAppHost>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -96,7 +97,7 @@
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#F8FAFC" BaseSize="128,128" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
@@ -125,6 +126,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="icon.ico" />
|
||||
<EmbeddedResource Include="Platforms\Android\Resources\drawable\icon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -137,7 +139,7 @@
|
||||
|
||||
<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="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
|
||||
@@ -161,7 +163,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Build web assets into MAUI `wwwroot` so both Windows publish and Android packaging can reuse the same folder. -->
|
||||
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(MSBuildProjectDirectory)\wwwroot\index.html'))">
|
||||
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="Exists('$(TodoWebDir)') And (!Exists('$(MSBuildProjectDirectory)\wwwroot\index.html') Or '$(SkipWebBuild)' != 'true' Or '$(ForceWebBuild)' == 'true')">
|
||||
<Exec Command="npm ci" WorkingDirectory="$(TodoWebDir)" Condition="Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
|
||||
<Exec Command="npm install" WorkingDirectory="$(TodoWebDir)" Condition="!Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
|
||||
<Exec Command="npm run build:maui" WorkingDirectory="$(TodoWebDir)" />
|
||||
@@ -179,21 +181,33 @@
|
||||
|
||||
<ItemGroup>
|
||||
<MauiAsset Include="@(_TodoWebDistFiles)">
|
||||
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<LogicalName>$([System.String]::Copy('wwwroot/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))</LogicalName>
|
||||
</MauiAsset>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(MSBuildProjectDirectory)\wwwroot\index.html')">
|
||||
<Target Name="IncludeMauiWwwrootAsMauiAssets" BeforeTargets="BeforeBuild;ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android'">
|
||||
<ItemGroup>
|
||||
<_MauiWwwrootFiles Include="$(MSBuildProjectDirectory)\wwwroot\**\*" />
|
||||
<!-- 确保包含所有 wwwroot 下的文件,包括子目录。 -->
|
||||
<_MauiWwwrootFiles Include="wwwroot\**\*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 显式设置 Link 和 LogicalName。
|
||||
在 Android 上,LogicalName 决定了 AssetManager.Open() 的路径。 -->
|
||||
<MauiAsset Include="@(_MauiWwwrootFiles)">
|
||||
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
|
||||
<Link>wwwroot\%(RecursiveDir)%(Filename)%(Extension)</Link>
|
||||
<LogicalName>$([System.String]::Copy('wwwroot/%(RecursiveDir)%(Filename)%(Extension)').Replace('\', '/'))</LogicalName>
|
||||
</MauiAsset>
|
||||
|
||||
<!-- 同时尝试在根目录也放一份 index.html,增加容错。 -->
|
||||
<MauiAsset Include="wwwroot\index.html" Condition="Exists('wwwroot\index.html')">
|
||||
<Link>index.html</Link>
|
||||
<LogicalName>index.html</LogicalName>
|
||||
</MauiAsset>
|
||||
</ItemGroup>
|
||||
|
||||
<Message Importance="high" Text="[Build] Included $(_MauiWwwrootFiles->Count()) assets from wwwroot into MauiAsset (with wwwroot/ prefix and root fallback)" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -212,3 +226,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -100,8 +100,9 @@ public static partial class MauiProgram
|
||||
{
|
||||
try
|
||||
{
|
||||
InitializeDatabase(app.Services, connectionString);
|
||||
// 优先启动内嵌 WebServer,确保 WebView 加载时不再 ERR_CONNECTION_REFUSED。
|
||||
await StartWebServer(app.Services);
|
||||
InitializeDatabase(app.Services, connectionString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application android:allowBackup="true" android:icon="@drawable/appicon" android:roundIcon="@drawable/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" package="com.hua.todo">
|
||||
<application android:allowBackup="true" android:icon="@drawable/appicon" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config" android:label="To&Do">
|
||||
<provider android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" />
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using AndroidX.Core.View;
|
||||
|
||||
namespace Hua.Todo.Maui;
|
||||
|
||||
@@ -18,8 +19,60 @@ public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
|
||||
ConfigureSystemBars();
|
||||
|
||||
#if DEBUG
|
||||
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activity 恢复时回调。
|
||||
/// 某些设备/启动链路会在恢复阶段重置系统栏样式,这里再次应用。
|
||||
/// </summary>
|
||||
protected override void OnResume()
|
||||
{
|
||||
base.OnResume();
|
||||
ConfigureSystemBars();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 窗口焦点变化回调。
|
||||
/// 焦点切换后部分系统会重新应用主题色,需再次修正。
|
||||
/// </summary>
|
||||
/// <param name="hasFocus">是否获得焦点。</param>
|
||||
public override void OnWindowFocusChanged(bool hasFocus)
|
||||
{
|
||||
base.OnWindowFocusChanged(hasFocus);
|
||||
if (hasFocus)
|
||||
{
|
||||
ConfigureSystemBars();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 Android 系统栏样式,避免默认紫色状态栏与页面视觉割裂。
|
||||
/// </summary>
|
||||
private void ConfigureSystemBars()
|
||||
{
|
||||
if (Window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用接近页面背景的浅色系统栏,弱化顶部挖孔区域的突兀感。
|
||||
var systemBarColor = global::Android.Graphics.Color.ParseColor("#F8FAFC");
|
||||
Window.SetStatusBarColor(systemBarColor);
|
||||
Window.SetNavigationBarColor(systemBarColor);
|
||||
|
||||
// 保持内容在系统栏下方,避免顶部控件贴到状态栏区域。
|
||||
WindowCompat.SetDecorFitsSystemWindows(Window, true);
|
||||
|
||||
var insetsController = WindowCompat.GetInsetsController(Window, Window.DecorView);
|
||||
if (insetsController is not null)
|
||||
{
|
||||
insetsController.AppearanceLightStatusBars = true;
|
||||
insetsController.AppearanceLightNavigationBars = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
if (_listener != null) return Task.CompletedTask;
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_listener = new TcpListener(IPAddress.Loopback, _appSettings.WebServer.Port);
|
||||
// 使用 IPAddress.Any 而非 Loopback,避免 localhost 解析到 IPv6 [::1] 时连接被拒绝。
|
||||
// 同时确保在 Android 模拟器/设备上 localhost 能正确连通。
|
||||
_listener = new TcpListener(IPAddress.Any, _appSettings.WebServer.Port);
|
||||
_listener.Start();
|
||||
|
||||
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
|
||||
@@ -257,12 +259,42 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WebServer] Serving static: {path} -> {assetPath}");
|
||||
|
||||
if (!TryOpenAsset(assetPath, out var assetStream))
|
||||
{
|
||||
// 如果 wwwroot/path 找不到,尝试直接加载 path(应对路径扁平化)
|
||||
var fallbackAssetPath = path.TrimStart('/');
|
||||
if (!string.IsNullOrEmpty(fallbackAssetPath) && !fallbackAssetPath.StartsWith("wwwroot", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($"[WebServer] {assetPath} not found, trying fallback path: {fallbackAssetPath}");
|
||||
if (TryOpenAsset(fallbackAssetPath, out assetStream))
|
||||
{
|
||||
await using (assetStream)
|
||||
{
|
||||
await WriteStreamAsync(stream, 200, assetStream, GetContentType(path), token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Path.HasExtension(path))
|
||||
{
|
||||
assetPath = "wwwroot/index.html";
|
||||
if (TryOpenAsset(assetPath, out assetStream))
|
||||
var fallbackHtmlPath = "wwwroot/index.html";
|
||||
Console.WriteLine($"[WebServer] {assetPath} not found, trying SPA fallback: {fallbackHtmlPath}");
|
||||
if (TryOpenAsset(fallbackHtmlPath, out assetStream))
|
||||
{
|
||||
await using (assetStream)
|
||||
{
|
||||
await WriteStreamAsync(stream, 200, assetStream, "text/html; charset=utf-8", token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 再次尝试不带 wwwroot 的 index.html
|
||||
fallbackHtmlPath = "index.html";
|
||||
Console.WriteLine($"[WebServer] {assetPath} not found, trying SPA fallback: {fallbackHtmlPath}");
|
||||
if (TryOpenAsset(fallbackHtmlPath, out assetStream))
|
||||
{
|
||||
await using (assetStream)
|
||||
{
|
||||
@@ -272,6 +304,7 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[WebServer] 404 Not Found: {assetPath}");
|
||||
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
|
||||
return;
|
||||
}
|
||||
@@ -286,11 +319,43 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
{
|
||||
try
|
||||
{
|
||||
stream = global::Android.App.Application.Context.Assets.Open(assetPath);
|
||||
// Android AssetManager.Open() 路径不能以 / 开头,且必须使用正斜杠
|
||||
var normalizedPath = assetPath.TrimStart('/').Replace('\\', '/');
|
||||
stream = global::Android.App.Application.Context.Assets.Open(normalizedPath);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[WebServer] Failed to open asset '{assetPath}': {ex.Message}");
|
||||
|
||||
// 只在 wwwroot/index.html 失败时尝试列出文件,帮助排查打包结构
|
||||
if (assetPath.EndsWith("index.html", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
// 列出 root assets 和 wwwroot 目录,帮助排查打包结构
|
||||
var rootAssets = global::Android.App.Application.Context.Assets.List("");
|
||||
if (rootAssets != null)
|
||||
{
|
||||
Console.WriteLine($"[WebServer] Root Assets: {string.Join(", ", rootAssets)}");
|
||||
}
|
||||
|
||||
var assets = global::Android.App.Application.Context.Assets.List("wwwroot");
|
||||
if (assets != null && assets.Length > 0)
|
||||
{
|
||||
Console.WriteLine($"[WebServer] Assets in 'wwwroot/': {string.Join(", ", assets)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("[WebServer] 'wwwroot/' directory is empty or not found in assets.");
|
||||
}
|
||||
}
|
||||
catch (Exception listEx)
|
||||
{
|
||||
Console.WriteLine($"[WebServer] Failed to list assets: {listEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
stream = Stream.Null;
|
||||
return false;
|
||||
}
|
||||
@@ -423,6 +488,8 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
var requestLine = await reader.ReadLineAsync(token);
|
||||
if (string.IsNullOrWhiteSpace(requestLine)) throw new InvalidOperationException("empty request line");
|
||||
|
||||
Console.WriteLine($"[WebServer] Raw Request Line: {requestLine}");
|
||||
|
||||
var parts = requestLine.Split(' ');
|
||||
if (parts.Length < 2) throw new InvalidOperationException("invalid request line");
|
||||
|
||||
@@ -430,11 +497,19 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
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)))
|
||||
if (target.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
target.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = absoluteUri.AbsolutePath;
|
||||
// WebView 有时会发送绝对 URL(例如 GET http://localhost:5057/ HTTP/1.1)
|
||||
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri))
|
||||
{
|
||||
path = absoluteUri.AbsolutePath;
|
||||
Console.WriteLine($"[WebServer] Parsed path from absolute URI: {path}");
|
||||
}
|
||||
else
|
||||
{
|
||||
path = target;
|
||||
}
|
||||
}
|
||||
else if (target.StartsWith("//", StringComparison.Ordinal) &&
|
||||
Uri.TryCreate("http:" + target, UriKind.Absolute, out var authorityUri))
|
||||
@@ -451,6 +526,8 @@ public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
|
||||
if (string.IsNullOrEmpty(path)) path = "/";
|
||||
if (!path.StartsWith("/", StringComparison.Ordinal)) path = "/" + path;
|
||||
|
||||
Console.WriteLine($"[WebServer] Final Resolved Path: {path}");
|
||||
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? line;
|
||||
while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(token)))
|
||||
|
||||
|
After Width: | Height: | Size: 192 KiB |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#512BD4</color>
|
||||
<color name="colorPrimaryDark">#2B0B98</color>
|
||||
<color name="colorAccent">#2B0B98</color>
|
||||
</resources>
|
||||
<color name="colorPrimary">#F8FAFC</color>
|
||||
<color name="colorPrimaryDark">#E2E8F0</color>
|
||||
<color name="colorAccent">#2563EB</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Maui.Controls;
|
||||
using Microsoft.Maui.ApplicationModel;
|
||||
using Microsoft.Maui.Handlers;
|
||||
|
||||
namespace Hua.Todo.Maui.Views
|
||||
{
|
||||
@@ -32,6 +33,20 @@ namespace Hua.Todo.Maui.Views
|
||||
windowService.MinimizeWindow(window);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 WebView 滚动条行为。
|
||||
/// </summary>
|
||||
partial void ConfigureWebViewScrollBars()
|
||||
{
|
||||
// 使用 JavaScript 注入的方式禁用滚动条
|
||||
MainWebView.EvaluateJavaScriptAsync(@"
|
||||
// 禁用页面垂直滚动条
|
||||
document.body.style.overflowY = 'hidden';
|
||||
// 禁用整个文档的滚动
|
||||
document.documentElement.style.overflowY = 'hidden';
|
||||
");
|
||||
}
|
||||
|
||||
partial void PlatformPrepareWebViewContainer()
|
||||
{
|
||||
if (Platforms.Windows.WebView2RuntimeDetector.IsRuntimeInstalled(out _))
|
||||
|
||||
@@ -119,9 +119,17 @@ namespace Hua.Todo.Maui.Views
|
||||
}
|
||||
});
|
||||
");
|
||||
|
||||
// 配置 WebView 滚动条行为(平台特定实现)
|
||||
ConfigureWebViewScrollBars();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置 WebView 滚动条行为(平台特定实现)。
|
||||
/// </summary>
|
||||
partial void ConfigureWebViewScrollBars();
|
||||
|
||||
/// <summary>
|
||||
/// 规格化 URL(针对 Android 模拟器处理 localhost)。
|
||||
/// Android 模拟器中 localhost 指向模拟器自身,需要替换为 10.0.2.2 才能访问宿主机服务。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#define MyAppName "Hua.Todo"
|
||||
#define MyAppVersion "1.2.0"
|
||||
#define MyAppVersion "1.2.7"
|
||||
#define MyAppPublisher "ShaoHua"
|
||||
#define MyAppURL "https://git.we965.cn/Tools/Hua.Todo"
|
||||
#define MyAppExeName "Hua.Todo.Maui.exe"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "Hua.Todo-web",
|
||||
"name": "Hua.Todo-Web",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "Hua.Todo-web",
|
||||
"name": "Hua.Todo-Web",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 228 B |
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import CloudSyncStorage from '../services/cloudSyncStorage';
|
||||
import { cloudSyncState } from '../services/cloudSyncState';
|
||||
|
||||
const showNotification = (message: string, type: 'error' | 'success' = 'error') => {
|
||||
if ((window as any).showToast) {
|
||||
@@ -52,6 +53,15 @@ cloudClient.interceptors.response.use(
|
||||
let errorMessage = '网络错误,请稍后再试';
|
||||
|
||||
const status = error?.response?.status as number | undefined;
|
||||
const errorCode = error?.response?.data?.code as string | undefined;
|
||||
|
||||
if (status === 403 && errorCode === 'SECOND_FACTOR_REQUIRED') {
|
||||
// 捕获“需要二次认证”错误,触发二次认证弹窗,并中断当前请求(不弹普通错误提示)
|
||||
cloudSyncState.isSteppingUp = true;
|
||||
// 注意:这里我们暂不自动重试,让用户在弹窗输入密码后再手动触发(或由 UI 层处理重试)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
CloudSyncStorage.clearSession();
|
||||
requestCloudSyncReLogin();
|
||||
|
||||
@@ -25,6 +25,20 @@ export interface CloudTaskItem {
|
||||
parentTaskId?: number | null;
|
||||
}
|
||||
|
||||
export interface SecurityPolicyResponse {
|
||||
allowPersist: boolean;
|
||||
allowSync: boolean;
|
||||
requireSecondFactorFor: string[];
|
||||
}
|
||||
|
||||
export interface StepUpRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface StepUpResponse {
|
||||
stepUpExpiresAtUtc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 CloudSync 的扁平任务列表(ParentTaskId)组装成前端需要的树结构(subTasks)。
|
||||
*/
|
||||
@@ -93,6 +107,22 @@ export const cloudSyncApi = {
|
||||
const items = Array.isArray(response.data) ? response.data : [];
|
||||
return buildTaskTreeFromCloudItems(items);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前用户的安全策略。
|
||||
*/
|
||||
async getSecurityPolicy(): Promise<SecurityPolicyResponse> {
|
||||
const response = await cloudClient.get<SecurityPolicyResponse>('/security/policy');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行二次认证(Step-up)。
|
||||
*/
|
||||
async stepUp(password: string): Promise<StepUpResponse> {
|
||||
const response = await cloudClient.post<StepUpResponse>('/auth/step-up', { password });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default cloudSyncApi;
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
<div class="hint">
|
||||
已登录:<span class="mono">{{ sessionSummary }}</span>
|
||||
</div>
|
||||
<div v-if="policy" class="policy-info">
|
||||
<div class="policy-item">
|
||||
<span class="label">落盘策略:</span>
|
||||
<span :class="policy.allowPersist ? 'success' : 'warn'">
|
||||
{{ policy.allowPersist ? '允许(本地持久化)' : '禁止(仅限内存)' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="policy-item">
|
||||
<span class="label">同步权限:</span>
|
||||
<span :class="policy.allowSync ? 'success' : 'warn'">
|
||||
{{ policy.allowSync ? '正常' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="btn btn-secondary" @click="logout">登出</button>
|
||||
<button class="btn btn-primary" :disabled="!localEnabled" @click="pullTasks">
|
||||
@@ -86,13 +100,43 @@
|
||||
<button class="btn btn-secondary" @click="close">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step-up (2FA) Overlay -->
|
||||
<div v-if="cloudSyncState.isSteppingUp" class="overlay step-up-overlay" @click.self="cancelStepUp">
|
||||
<div class="dialog step-up-dialog">
|
||||
<div class="dialog-header">
|
||||
<h2>🔐 二次认证</h2>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="hint">当前操作属于高风险操作,请验证您的登录密码。</div>
|
||||
<div class="form-row">
|
||||
<input
|
||||
v-model="stepUpPassword"
|
||||
class="text-input"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="confirmStepUp"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="stepUpError" class="error-text">{{ stepUpError }}</div>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button class="btn btn-secondary" @click="cancelStepUp">取消</button>
|
||||
<button class="btn btn-primary" :disabled="!stepUpPassword || isSteppingUp" @click="confirmStepUp">
|
||||
{{ isSteppingUp ? '验证中...' : '确认' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import CloudSyncStorage from '../services/cloudSyncStorage';
|
||||
import cloudSyncApi from '../api/cloudSync';
|
||||
import cloudSyncApi, { type SecurityPolicyResponse } from '../api/cloudSync';
|
||||
import LocalStorageService from '../services/localStorageService';
|
||||
import { cloudSyncState } from '../services/cloudSyncState';
|
||||
|
||||
type ProbeType = 'success' | 'warn' | 'error';
|
||||
interface ProbeResult {
|
||||
@@ -112,11 +156,33 @@ const userName = ref('');
|
||||
const password = ref('');
|
||||
const isLoggingIn = ref(false);
|
||||
|
||||
const policy = ref<SecurityPolicyResponse | null>(null);
|
||||
|
||||
const stepUpPassword = ref('');
|
||||
const stepUpError = ref('');
|
||||
const isSteppingUp = ref(false);
|
||||
|
||||
const loadLocalState = () => {
|
||||
const settings = CloudSyncStorage.loadSettings();
|
||||
localEnabled.value = settings.enabled;
|
||||
serverUrlSaved.value = settings.serverUrl;
|
||||
serverUrlInput.value = settings.serverUrl;
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
refreshPolicy();
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPolicy = async () => {
|
||||
try {
|
||||
const p = await cloudSyncApi.getSecurityPolicy();
|
||||
policy.value = p;
|
||||
cloudSyncState.policy = p;
|
||||
// 更新落盘策略
|
||||
LocalStorageService.setAllowPersist(p.allowPersist);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch security policy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = computed(() => Boolean(CloudSyncStorage.loadSession()?.accessToken));
|
||||
@@ -280,6 +346,7 @@ const login = async () => {
|
||||
password: password.value,
|
||||
});
|
||||
showToast('登录成功', 'success');
|
||||
await refreshPolicy();
|
||||
emitCloudSyncStateChanged();
|
||||
await pullTasks();
|
||||
} finally {
|
||||
@@ -289,10 +356,41 @@ const login = async () => {
|
||||
|
||||
const logout = () => {
|
||||
cloudSyncApi.logout();
|
||||
policy.value = null;
|
||||
cloudSyncState.policy = { allowPersist: true, allowSync: true, requireSecondFactorFor: [] };
|
||||
LocalStorageService.setAllowPersist(true); // 登出后重置
|
||||
showToast('已登出', 'success');
|
||||
emitCloudSyncStateChanged();
|
||||
};
|
||||
|
||||
const confirmStepUp = async () => {
|
||||
if (!stepUpPassword.value || isSteppingUp.value) return;
|
||||
isSteppingUp.value = true;
|
||||
stepUpError.value = '';
|
||||
try {
|
||||
await cloudSyncApi.stepUp(stepUpPassword.value);
|
||||
showToast('二次认证成功', 'success');
|
||||
cloudSyncState.isSteppingUp = false;
|
||||
stepUpPassword.value = '';
|
||||
// 如果有挂起的回调,执行它
|
||||
if (cloudSyncState.onStepUpSuccess) {
|
||||
cloudSyncState.onStepUpSuccess();
|
||||
cloudSyncState.onStepUpSuccess = null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
stepUpError.value = '认证失败,请检查密码';
|
||||
} finally {
|
||||
isSteppingUp.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelStepUp = () => {
|
||||
cloudSyncState.isSteppingUp = false;
|
||||
cloudSyncState.onStepUpSuccess = null;
|
||||
stepUpPassword.value = '';
|
||||
stepUpError.value = '';
|
||||
};
|
||||
|
||||
const pullTasks = async () => {
|
||||
const event = new CustomEvent('cloudSyncPullTasksRequested');
|
||||
window.dispatchEvent(event);
|
||||
@@ -310,6 +408,11 @@ function close() {
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('openCloudSyncSettings', handleOpenSettings);
|
||||
|
||||
// 初始化:如果已登录,自动获取策略
|
||||
if (isLoggedIn.value) {
|
||||
refreshPolicy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -421,6 +524,39 @@ onMounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.policy-info {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.policy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.policy-item .label {
|
||||
color: #6b7280;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.policy-item .success {
|
||||
color: #059669;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.policy-item .warn {
|
||||
color: #d97706;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -497,6 +633,21 @@ onMounted(() => {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.step-up-overlay {
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.step-up-dialog {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #ef4444;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { reactive } from 'vue';
|
||||
import type { CloudSyncSession } from '../services/cloudSyncStorage';
|
||||
|
||||
/**
|
||||
* 云同步运行时状态。
|
||||
* 此状态仅保留在内存中,刷新页面即丢失。
|
||||
*/
|
||||
export const cloudSyncState = reactive({
|
||||
/**
|
||||
* 当前会话信息(从 CloudSyncStorage 加载或登录后设置)。
|
||||
*/
|
||||
session: null as CloudSyncSession | null,
|
||||
|
||||
/**
|
||||
* 服务端下发的安全策略。
|
||||
*/
|
||||
policy: {
|
||||
allowPersist: true,
|
||||
allowSync: true,
|
||||
requireSecondFactorFor: [] as string[],
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否正在进行二次认证流程。
|
||||
*/
|
||||
isSteppingUp: false,
|
||||
|
||||
/**
|
||||
* 二次认证成功后的回调(用于重试之前失败的操作)。
|
||||
*/
|
||||
onStepUpSuccess: null as (() => void) | null,
|
||||
});
|
||||
|
||||
export default cloudSyncState;
|
||||
@@ -4,6 +4,20 @@ import { normalizeTasks } from './taskNormalizer';
|
||||
const STORAGE_KEY = 'Hua.Todo_tasks';
|
||||
const SYNC_STATUS_KEY = 'Hua.Todo_sync_status';
|
||||
|
||||
/**
|
||||
* 内存存储备份(当禁止落盘时使用)。
|
||||
*/
|
||||
const memoryStore = {
|
||||
tasks: [] as Task[],
|
||||
syncStatus: null as SyncStatus | null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 当前是否允许落盘。
|
||||
* 由外部(如 CloudSyncSettingsDialog)根据服务端策略动态设置。
|
||||
*/
|
||||
let allowPersist = true;
|
||||
|
||||
export interface SyncStatus {
|
||||
lastSyncTime: number;
|
||||
isOnline: boolean;
|
||||
@@ -11,15 +25,38 @@ export interface SyncStatus {
|
||||
}
|
||||
|
||||
export class LocalStorageService {
|
||||
/**
|
||||
* 设置落盘策略。若从允许切换到禁止,则清空已落盘数据。
|
||||
*/
|
||||
static setAllowPersist(allowed: boolean): void {
|
||||
const previous = allowPersist;
|
||||
allowPersist = allowed;
|
||||
if (previous && !allowed) {
|
||||
this.clearTasks();
|
||||
localStorage.removeItem(SYNC_STATUS_KEY);
|
||||
console.log('CloudSync: Security policy changed to "No Persist", local data cleared.');
|
||||
}
|
||||
}
|
||||
|
||||
static saveTasks(tasks: Task[]): void {
|
||||
const normalized = normalizeTasks(tasks);
|
||||
if (!allowPersist) {
|
||||
memoryStore.tasks = normalized;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeTasks(tasks)));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized));
|
||||
} catch (error) {
|
||||
console.error('Failed to save tasks to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static loadTasks(): Task[] {
|
||||
if (!allowPersist) {
|
||||
return memoryStore.tasks;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? normalizeTasks(JSON.parse(data)) : [];
|
||||
@@ -30,6 +67,7 @@ export class LocalStorageService {
|
||||
}
|
||||
|
||||
static clearTasks(): void {
|
||||
memoryStore.tasks = [];
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
@@ -38,6 +76,11 @@ export class LocalStorageService {
|
||||
}
|
||||
|
||||
static saveSyncStatus(status: SyncStatus): void {
|
||||
if (!allowPersist) {
|
||||
memoryStore.syncStatus = status;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(SYNC_STATUS_KEY, JSON.stringify(status));
|
||||
} catch (error) {
|
||||
@@ -46,6 +89,10 @@ export class LocalStorageService {
|
||||
}
|
||||
|
||||
static loadSyncStatus(): SyncStatus {
|
||||
if (!allowPersist && memoryStore.syncStatus) {
|
||||
return memoryStore.syncStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = localStorage.getItem(SYNC_STATUS_KEY);
|
||||
return data ? JSON.parse(data) : {
|
||||
|
||||