Compare commits

...

20 Commits

Author SHA1 Message Date
ShaoHua 3dbd97103c feat:1.2.0初始版本未自测 2026-04-13 23:10:07 +08:00
ShaoHua 1f87565d5a 1.MAUI Android可以正常显示 2026-04-13 21:17:15 +08:00
ShaoHua d94d1f36d2 1.maui支持Android版本 2026-04-13 00:06:53 +08:00
ShaoHua d53828c150 feat: v1.2.0 开发进度更新
### 新增功能
- **Linux 官方支持**:新增 Hua.Todo.Avalonia 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化
- **SQLite DateTime 兼容修复**:新增 LenientUtcDateTimeStringConverter,解决历史遗留的 DateTime 脏数据解析问题
- **用户文档完善**:新增 docs/manual/新手指南.md 和 docs/manual/用户指南.md
- **部署文档**:新增 docs/manual/部署文档.md,详细说明多平台发布流程

### 优化与修复
- **发布脚本整理**:拆分/对齐各平台发布入口,新增 publish.ps1 作为统一入口
- **Windows WebView2 优化**:数据目录调整到 %LocalAppData%\Hua.Todo\WebView2,修复 Runtime 误判问题
- **MAUI 多平台构建**:在 Windows 开发机上默认仅构建 Android + Windows 目标
- **SPA 路由回落**:修复 Release 模式下 /swagger 路径的 404 问题
- **Swagger 输出**:补齐 Dynamic API 端点,避免接口缺失

### 文档更新
- **版本记录**:更新 v1.2.0 开发进度和功能列表
- **技术设计文档**:添加 Avalonia 项目架构和模块设计
- **项目结构**:更新 README.md 中的项目结构说明

### 其他变更
- 新增 Directory.Build.props 和更新 Directory.Build.targets
- 调整 src/Hua.Todo.Avalonia 项目配置和资源文件
- 更新 src/Hua.Todo.Web 前端资源文件
- 修复 src/Hua.Todo.Maui 相关配置和打包脚本
2026-04-09 21:39:07 +08:00
ShaoHua 8443d14ba6 发布版本 2026-04-08 20:16:04 +08:00
ShaoHua 04263dff4e refactor: 重构待办事项模块结构与命名 2026-04-08 19:59:50 +08:00
ShaoHua 7a4c516a20 feat: 引入 CloudSync 核心能力并新增 Avalonia 桌面端与发布脚本
- 后端:新增 CloudSync 认证/权限/端点/服务与 DTO
- 数据:新增用户/会话/安全策略实体与 EF Core migrations
- 前端:新增云同步设置 UI、客户端与本地存储;Vite 支持 maui 构建输出到 wwwroot
- 桌面端:新增 Avalonia 项目、内置 WebServer、托盘与 Windows 全局热键
- 发布/构建:新增 Windows/Linux 发布脚本与统一入口;调整 MAUI 资源与安装包配置
- 文档:同步更新 README/docs 与协作规则
2026-04-07 03:34:34 +08:00
ShaoHua 18d37fdd24 doc:输出1.2.0版本需求文档 2026-04-07 00:36:39 +08:00
ShaoHua 141b3112a4 doc:整理文档 2026-04-06 23:25:57 +08:00
ShaoHua d00a907da0 refactor:规范代码格式和注释 2026-04-06 22:59:16 +08:00
ShaoHua 758f6772c6 1.更换软件协议为AGPL
2.切换项目名称为Hua.Todo
2026-04-06 22:06:30 +08:00
ShaoHua 40a91e39b6 feat: 优化 Android 支持并实现前端自动化构建集成
- 新增 Android 专用的嵌入式 Web 服务器,通过 TcpListener 提供静态资源服务
- 在 .csproj 中集成 MSBuild 任务,支持自动构建 TodoList.Web 并同步至 wwwroot
- 重构 MainPage 以支持依赖注入,并处理 Android 模拟器 localhost (10.0.2.2) 映射
- 优化 Android 调试配置,包括快速部署、ABI 限制及禁用资源缩放
- 添加 Android 矢量图标资源,并更新默认配置以启用静态文件模式
2026-04-06 21:07:10 +08:00
ShaoHua 4daa0c4eba 移除1.0.0版本 2026-04-05 00:57:04 +08:00
ShaoHua ceb77e624e feat:基础功能实现
feat: 重构 TodoList 架构,新增动态 API 与 MAUI 内嵌 Web 服务
feat:优化交互逻辑,优化发布流程
2026-04-05 00:53:42 +08:00
ShaoHua ed3d90cd7a 添加仓库链接 2026-01-01 05:18:39 +08:00
ShaoHua c6636be0c3 允许编辑 2026-01-01 03:30:22 +08:00
ShaoHua 036dc6964a ui调整 2026-01-01 03:15:25 +08:00
ShaoHua 8beb6b7a1e 允许排序 2026-01-01 02:48:58 +08:00
ShaoHua 03da513632 忽略安装包文件 2025-12-31 04:09:24 +08:00
ShaoHua 83ad246708 初始版本 2025-12-31 04:06:10 +08:00
278 changed files with 36824 additions and 2308 deletions
+14 -1
View File
@@ -37,6 +37,7 @@ bld/
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
src/Hua.Todo.Maui/wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
@@ -360,4 +361,16 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
/Setup/Output
/Hua.Todo/Output
/src/Hua.Todo.Maui/Output
/src/Hua.Todo.Host/Hua.Todo.db
/src/Hua.Todo.Host/Hua.Todo.db-shm
/src/Hua.Todo.Host/Hua.Todo.db-wal
/.artifacts/buildcheck
/.trae
/.artifacts
/.android-sdk
/src/Hua.Todo.Avalonia/wwwroot
/src/Hua.Todo.Avalonia/wwwroot
+35
View File
@@ -0,0 +1,35 @@
{
"version": "0.2.0",
"configurations": [
{
// 使用 IntelliSense 找出 C# 调试存在哪些属性
// 将悬停用于现有属性的说明
// 有关详细信息,请访问 https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md。
"name": ".NET Core Launch (web)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// 如果已更改目标框架,请确保更新程序路径。
"program": "${workspaceFolder}/src/Hua.Todo.Host/bin/Debug/net10.0/Hua.Todo.Host.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Hua.Todo.Host",
"stopAtEntry": false,
// 启用在启动 ASP.NET Core 时启动 Web 浏览器。有关详细信息: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
}
]
}
+2
View File
@@ -0,0 +1,2 @@
{
}
+43
View File
@@ -0,0 +1,43 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj",
"-c",
"Release",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/src/Hua.Todo.Host/Hua.Todo.Host.csproj"
],
"problemMatcher": "$msCompile"
}
]
}
+25
View File
@@ -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>
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup Condition="'$(TargetFramework)' != ''">
<!-- UseMonoRuntime 仅在 Android 目标启用;当显式指定桌面 RIDwin-*/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>
+17
View File
@@ -0,0 +1,17 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/Hua.Todo.Application/Hua.Todo.Application.csproj" />
<Project Path="src/Hua.Todo.Avalonia/Hua.Todo.Avalonia.csproj" />
<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">
<Deploy Solution="Debug|Any CPU" />
</Project>
</Folder>
</Solution>
+664
View File
@@ -0,0 +1,664 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under
section 7. This requirement modifies the requirement in
section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have received it separately.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has
interactive interfaces that do not display Appropriate Legal
Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
Machine-Readable Corresponding Source under the terms of this
License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that
product model, to give anyone who possesses the object code
either (1) a copy of the Corresponding Source for all the
software in the product that is covered by this License, on a
durable physical medium customarily used for software
interchange, for a price no more than your reasonable cost of
physically performing this conveying of source, or (2) access to
copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in
accord with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to
the Corresponding Source in the same way through the same place
at no further charge. You need not require recipients to copy
the Corresponding Source along with the object code. If the
place to copy the object code is a network server, the
Corresponding Source may be on a different server (operated by
you or a third party) that supports equivalent copying
facilities, provided you maintain clear directions next to the
object code saying where the Corresponding Source is located.
Regardless of what server hosts the Corresponding Source, you
remain obligated to ensure that it is available for as long as
needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal,
family, or household purposes, or (2) anything designed or sold for
incorporation into a dwelling. In determining whether a product is a
consumer product, doubtful cases shall be resolved in favor of
coverage. For a particular product received by a particular user,
"normally used" refers to a typical or common use of that class of
product, regardless of the status of the particular user or of the way
in which the particular user actually uses, or expects or is expected
to use, the product. A product is a consumer product regardless of
whether the product has substantial commercial, industrial or
non-consumer uses, unless such uses represent the only significant
mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to
install and execute modified versions of a covered work in that User
Product from a modified version of its Corresponding Source. The
information must suffice to ensure that the continued functioning of
the modified object code is in no case prevented or interfered with
solely because modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a surcharge for exercising rights granted under this
License, and you may not impose any litigation (including a
cross-claim or counterclaim in a lawsuit) alleging that any patent
claim is infringed by making, using, selling, offering for sale, or
importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor
version, but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make
payment to the third party based on the extent of your activity of
conveying the work, and under which the third party grants, to any of
the parties who would receive the covered work from you, a
discriminatory patent license (a) in connection with copies of the
covered work conveyed by you (or copies made from those copies), or
(b) primarily for and in connection with specific products or
compilations that contain the covered work, unless you entered into
that arrangement, or that patent license was granted, prior to 28
March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your
version supports such interaction) an opportunity to receive the
Corresponding Source of your version by providing access to the
Corresponding Source from a network server at no charge, through some
standard or customary means of facilitating copying of software. This
Corresponding Source shall include the Corresponding Source for any
work covered by version 3 of the GNU General Public License that is
incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Affero General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero
General Public License "or any later version" applies to it, you have
the option of following the terms and conditions either of that
numbered version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number
of the GNU Affero General Public License, you may choose any version
ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that
proxy's public statement of acceptance of a version permanently
authorizes you to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU
ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+229
View File
@@ -0,0 +1,229 @@
GNU AFFERO 通用公共许可证
第 3 版,2007 年 11 月 19 日
版权所有 © 2007 自由软件基金会公司 <https://fsf.org/>
每个人都被允许复制和分发本许可证文件的逐字副本,但不允许进行更改。
序言
GNU Affero 通用公共许可证是一个自由的、允许复制的软件和其他类型作品的许可,在网络服务器软件的情况下,它是专门为确保与社区合作而设计。
大多数软件和其他实用作品的许可都是为了剥夺您分享和改变作品的自由。相比之下,我们的通用公共许可证的目的是保证您分享和改变一个程序的所有版本的自由——确保它对所有用户都是自由软件。
当我们谈论自由软件时,我们指的是自由,而不是价格。我们的通用公共许可证的设计是为了确保您有分发自由软件副本的自由(如果您愿意,还可以收费),您可以收到源代码,或者如果您想得到它,您可以改变软件或在新的自由程序中使用它的片段,而且您知道您可以做这些事情。
使用我们通用公共许可证的开发者通过两个步骤保护您的权利:(1) 主张软件的版权,(2) 向您提供本许可证,允许您合法地复制、分发和/或修改该软件。
捍卫所有用户自由的一个次要好处是,如果程序的替代版本得到广泛使用,就可以供其他开发者使用。许多自由软件的开发者对由此产生的合作感到振奋和鼓舞。然而,在网络服务器上使用的软件,这种结果可能无法实现。GNU 通用公共许可证允许制作一个修改过的版本,让公众在服务器上访问它,而不需要向公众发布其源代码。
GNU Affero 通用公共许可证是专门设计来确保在这种情况下,修改后的源代码可以被社区使用。它要求网络服务器的运营商向该服务器的用户提供运行在那里的修改版本的源代码。因此,在一个可公开访问的服务器上公开使用一个修改过的版本,使公众能够获得修改过的版本的源代码。
一个较早的许可证,称为 Affero 通用公共许可证,由 Affero 发布,旨在实现类似目标。这是一个不同的许可证,不是 Affero GPL 的一个版本,但 Affero 已经发布了 Affero GPL 的一个新版本,允许在这个许可证下重新许可。
关于复制、分发和修改的确切条款和条件如下。
条款与条件
0. 定义.
“本许可证”是指 GNU Affero 通用公共许可证的第 3 版。
“版权”也指适用于其他类型作品的类似版权的法律,如半导体掩模。
“本程序”是指在本许可证下许可的任何有版权的作品。每个被许可人都被称呼为“您”。“被许可人”和“接受者”可以是个人或组织。
“修改”作品是指以需要版权许可的方式复制或改编该作品的全部或部分内容,而不是制作一个完全的副本。由此产生的作品被称为早期作品的“修改版”或“基于”早期作品的作品。
一个“涵盖的作品”是指未经修改的程序或基于该程序的作品。
“传播”作品是指在未经许可的情况下,对作品做任何事情,使您在适用的版权法下承担直接或间接的侵权责任,但在计算机上执行或修改私人副本除外。传播包括复制、分发(无论是否修改)、向公众提供,在一些国家还包括其他活动。
“传达”作品是指使其他各方能够制作或接受副本的任何一种传播。仅仅是通过计算机网络与用户互动,而没有转让副本,并不是传达。
交互式用户界面显示“适当的法律声明”的程度是,它包括一个方便和显眼的功能,(1) 显示适当的版权声明,(2) 告诉用户该作品没有保证(除了提供保证的范围),被许可人可以根据本许可传达该作品,以及如何查看本许可证的副本。如果界面呈现的是一个用户命令或选项的列表,如菜单,列表中的显著项符合这一标准。
1. 源代码.
作品的“源代码”是指对作品进行修改的首选形式。“目标代码”是指作品的任何非源代码形式。
“标准接口”是指由公认的标准机构定义的官方标准,或者在为特定编程语言指定的接口的情况下,在该语言的开发者中广泛使用的接口。
可执行作品的“系统库”包括除作品整体以外的任何内容:(a) 包含在主要组件包装的正常形式中,但不属于该主要组件的一部分,且 (b) 仅用于使作品能够与该主要组件一起使用,或用于实现公众可获得源代码形式的标准接口。在本文中,“主要组件”是指运行可执行作品的特定操作系统(如果有的话)的主要基本组件(内核、窗口系统等),或用于产生该作品的编译器,或用于运行它的目标代码解释器。
目标代码形式作品的“对应源代码”是指生成、安装和(对于可执行作品)运行目标代码以及修改作品所需的全部源代码,包括控制这些活动的脚本。但是,它不包括作品的系统库,或在执行这些活动时未经修改使用的通用工具或普遍可获得的自由程序,但它们不属于作品的一部分。例如,对应源代码包括与作品源文件相关的接口定义文件,以及作品被专门设计为需要的共享库和动态链接子程序的源代码,例如通过这些子程序与作品其他部分之间的密切数据通信或控制流。
对应源代码不需要包括用户可以从对应源代码的其他部分自动重新生成的任何内容。
源代码形式作品的对应源代码就是该作品本身。
2. 基本许可.
本许可证下授予的所有权利都是在程序的版权期限内授予的,并且在满足规定条件的情况下是不可撤销的。本许可证明确肯定了您运行未经修改程序的无限许可。运行涵盖作品的输出只有在输出内容构成涵盖作品的情况下才受本许可证约束。本许可证承认您的合理使用权或其他同等权利,正如版权法所规定的。
只要您的许可证仍然有效,您可以制作、运行和传播您不传达的涵盖作品,且无需遵守任何条件。您可以为了让他人专门为您进行修改,或为您提供运行这些作品的设施,而向他人传达涵盖作品,前提是您在传达所有您不控制版权的材料时遵守本许可证的条款。为您制作或运行涵盖作品的人必须完全代表您,在您的指示和控制下进行,其条款必须禁止他们在与您的关系之外制作您的版权材料的任何副本。
在任何其他情况下的传达只有在下述条件下才被允许。不允许转许可;第 10 条使其变得没有必要。
3. 保护用户的合法权利免受反绕过法的侵害.
根据任何履行 1996 年 12 月 20 日通过的 WIPO 版权条约第 11 条规定的义务的适用法律,或禁止或限制绕过此类措施的类似法律,任何涵盖作品都不应被视为有效技术措施的一部分。
当您传达涵盖作品时,您放弃任何禁止绕过技术措施的法律权利,其程度是此类绕过是通过行使本许可证下关于涵盖作品的权利而实现的,并且您放弃任何限制作品运行或修改的意图,作为针对作品用户执行您或第三方禁止绕过技术措施的合法权利的手段。
4. 传达逐字副本.
您可以按收到的程序源代码原样,通过任何媒介传达逐字副本,前提是您在每个副本上显著且适当地发布适当的版权声明;完整保留所有声明本许可证及根据第 7 条增加的任何非许可条款适用于该代码的通知;完整保留所有关于没有任何保证的通知;并随程序向所有接受者提供本许可证的副本。
您可以对传达的每个副本收取任何价格或不收取费用,并且您可以收取费用提供支持或保证保护。
5. 传达修改后的源代码版本.
您可以根据第 4 条的条款传达基于程序的作品,或从程序产生该作品的修改,以源代码的形式,前提是您还满足以下所有条件:
a) 作品必须带有显著的通知,说明您修改了它,并给出相关日期。
b) 作品必须带有显著的通知,说明它是根据本许可证和根据第 7 条增加的任何条件发布的。这一要求修改了第 4 条中“完整保留所有通知”的要求。
c) 您必须根据本许可证向任何拥有副本的人许可整个作品。因此,本许可证将连同任何适用的第 7 条附加条款一起适用于整个作品及其所有部分,无论它们是如何包装的。本许可证不准许以任何其他方式许可作品,但如果您单独收到了此类准许,它并不使该准许无效。
d) 如果作品有交互式用户界面,每个界面都必须显示适当的法律声明;但是,如果程序有交互式界面但不显示适当的法律声明,您的作品则不需要使它们显示。
将涵盖作品与其他单独且独立的作品(其性质不是涵盖作品的扩展,也不是与其组合形成更大的程序)在一个存储或分发介质卷中汇编,如果汇编及其产生的版权不被用于限制汇编用户的访问或合法权利,超出单个作品所允许的范围,则被称为“聚合体”。在聚合体中包含涵盖作品不会导致本许可证适用于聚合体的其他部分。
6. 传达非源代码形式.
您可以根据第 4 条和第 5 条的条款,以目标代码形式传达涵盖作品,前提是您还根据本许可证的条款,通过以下方式之一传达机器可读的对应源代码:
a) 在物理产品(包括物理分发介质)中或体现其中传达目标代码,随附固定在通常用于软件交换的耐用物理介质上的对应源代码。
b) 在物理产品(包括物理分发介质)中或体现其中传达目标代码,随附一份书面报价,有效期至少三年,并且在您为该产品型号提供备件或客户支持期间一直有效,向任何拥有目标代码的人提供 (1) 产品中受本许可证约束的所有软件的对应源代码副本,固定在通常用于软件交换的耐用物理介质上,价格不超过您物理执行此传达源代码的合理成本,或 (2) 从网络服务器免费下载对应源代码的访问权限。
c) 随附提供对应源代码的书面报价副本,传达目标代码的单个副本。这种替代方案只在偶尔且非商业的情况下被允许,并且只有在您根据第 6b 小节收到带有此类报价的目标代码时才被允许。
d) 通过从指定地点提供访问权限(免费或收费)传达目标代码,并以同样的方式通过同一地点提供对对应源代码的等效访问,不收取额外费用。您不需要要求接受者随目标代码一起复制对应源代码。如果复制目标代码的地点是网络服务器,对应源代码可以在不同的服务器上(由您或第三方运营),该服务器支持等效的复制设施,前提是您在目标代码旁边保留明确的指示,说明对应源代码的位置。无论哪个服务器托管对应源代码,您仍有义务确保它在满足这些要求所需的时间内是可用的。
e) 使用对等传输传达目标代码,前提是您告知其他对等方,根据第 6d 小节,该作品的目标代码和对应源代码正免费向公众提供。
目标代码的独立部分,其源代码作为系统库被排除在对应源代码之外,不需要在传达目标代码作品时包括在内。
“用户产品”是指 (1)“消费品”,指通常用于个人、家庭或家务目的的任何有形个人财产,或 (2) 任何设计或销售用于安装在住宅中的东西。在确定产品是否为消费品时,疑点应倾向于涵盖。对于特定用户收到的特定产品,“通常使用”是指该类产品的典型或常见用法,无论特定用户的身份或特定用户实际使用、预期或被预期使用产品的方式。产品是消费品,无论产品是否有实质性的商业、工业或非消费用途,除非此类用途代表了产品的唯一显著使用模式。
用户产品的“安装信息”是指从对应源代码的修改版在用户产品中安装和执行涵盖作品的修改版所需的任何方法、程序、授权密钥或其他信息。信息必须足以确保修改后的目标代码的持续运行在任何情况下都不会仅仅因为进行了修改而被阻止或干扰。
如果您根据本条在用户产品中、随附用户产品或专门用于用户产品传达目标代码作品,且传达作为永久或固定期限(无论交易如何描述)将用户产品的占有和使用权转让给接受者的交易的一部分发生,根据本条传达的对应源代码必须随附安装信息。但如果您或任何第三方都不保留在用户产品上安装修改后的目标代码的能力(例如,作品已安装在 ROM 中),则此要求不适用。
提供安装信息的要求不包括继续为由接受者修改或安装的作品,或为其被修改或安装的用户产品提供支持服务、保证或更新的要求。当修改本身实质性地且不利地影响网络的运行或违反跨网络通信的规则和协议时,可以拒绝访问网络。
根据本条传达的对应源代码和提供的安装信息必须采用公开记录的格式(且具有公众可获得源代码形式的实现),并且必须不需要特殊的密码或密钥进行解包、读取或复制。
7. 附加条款.
“附加许可”是通过对本许可证的一个或多个条件进行例外处理来补充其条款。适用于整个程序的附加许可应被视为包含在本许可证中,只要它们在适用法律下是有效的。如果附加许可仅适用于程序的一部分,该部分可以在这些许可下单独使用,但整个程序仍然受本许可证约束,而不考虑附加许可。
当您传达涵盖作品的副本时,您可以选择从该副本或其中的任何部分删除任何附加许可。(在您修改作品时,附加许可可以被编写为在某些情况下要求删除它们。)您可以为您拥有或可以提供适当版权许可的材料放置附加许可,并将其添加到涵盖作品中。
尽管有本许可证的任何其他规定,对于您添加到涵盖作品中的材料,您可以(如果得到该材料版权持有者的授权)用以下条款补充本许可证的条款:
a) 以不同于本许可证第 15 条和第 16 条条款的方式放弃保证或限制责任;或者
b) 要求在该材料中或在包含该材料的作品显示的适当法律声明中保留指定的合理法律声明或作者署名;或者
c) 禁止歪曲该材料的来源,或要求修改后的版本以合理的方式标记为不同于原始版本;或者
d) 限制出于宣传目的使用该材料的许可人或作者的名字;或者
e) 拒绝根据商标法授予使用某些商号、商标或服务标志的权利;或者
f) 要求该材料的传达者(或其修改版)向该材料的许可人和作者提供赔偿,如果传达者对接受者承担了合同责任,而这些合同责任直接强加给了这些许可人和作者。
所有其他非许可性的附加条款都被视为第 10 条意义上的“进一步限制”。如果您收到的程序或其任何部分包含一份声明其受本许可证约束的通知,以及一个属于进一步限制的条款,您可以删除该条款。如果一份许可证文件包含进一步限制,但允许在本许可证下重新许可或传达,您可以添加受该许可证文件条款约束的涵盖作品材料,前提是进一步限制在重新许可或传达后不再存续。
如果您根据本条向涵盖作品添加条款,您必须在相关的源文件中放置适用于这些文件的附加条款的声明,或指示在哪里可以找到适用条款的通知。
附加条款,无论是许可性的还是非许可性的,都可以以单独编写的许可证的形式陈述,或陈述为例外;上述要求在任何一种情况下都适用。
8. 终止.
除非本许可证明确规定,否则您不得传播或修改涵盖作品。任何以其他方式传播或修改作品的尝试都是无效的,并将自动终止您在本许可证下的权利(包括根据第 11 条第三段授予的任何专利许可)。
但是,如果您停止所有违反本许可证的行为,那么您从特定版权持有者处获得的许可证将恢复:(a) 临时恢复,除非且直到版权持有者明确且最终终止您的许可证,以及 (b) 永久恢复,如果版权持有者在停止后 60 天内未能通过某种合理方式通知您违反行为。
此外,如果版权持有者通过某种合理方式通知您违反行为,且这是您第一次收到该版权持有者关于违反本许可证(针对任何作品)的通知,并且您在收到通知后 30 天内纠正了违反行为,则您从该特定版权持有者处获得的许可证将永久恢复。
根据本条终止您的权利并不会终止根据本许可证从您那里获得副本或权利的各方的许可证。如果您的权利已被终止且未永久恢复,您就不符合根据第 10 条获得相同材料新许可证的资格。
9. 拥有副本不需要接受.
您不需要为了接收或运行程序的副本而接受本许可证。仅作为使用对等传输接收副本的结果而发生的涵盖作品的附属传播同样不需要接受。但是,除本许可证外,没有任何内容准许您传播或修改任何涵盖作品。如果您不接受本许可证,这些行为就侵犯了版权。因此,通过修改或传播涵盖作品,您表示您接受本许可证以这样做。
10. 下游接受者的自动许可.
每次您传达涵盖作品时,接受者会自动从原始许可人处获得根据本许可证运行、修改和传播该作品的许可。您不负责强制第三方遵守本许可证。
“实体交易”是指转移组织控制权、或其几乎所有资产、或细分组织、或合并组织的交易。如果涵盖作品的传播是由实体交易产生的,那么该交易中收到作品副本的每一方也会收到该方的利益前任在上一段下拥有或可以给予的关于该作品的任何许可,以及从利益前任那里获得作品对应源代码的权利(如果前任拥有它或可以通过合理的努力获得它)。
您不得对本许可证授予或确认的权利的行使施加任何进一步的限制。例如,您不得为行使本许可证授予的权利收取附加费,也不得发起任何诉讼(包括在诉讼中提出交叉索赔或反诉),指控程序的任何部分侵犯了任何专利权利。
11. 专利.
“贡献者”是指授权在本许可证下使用程序或程序所基于的作品的版权持有者。如此许可的作品被称为该贡献者的“贡献者版本”。
贡献者的“基本专利权利”是指贡献者拥有或控制的所有专利权利,无论是已经获得的还是以后获得的,如果以本许可证允许的某种方式制作、使用或销售其贡献者版本,就会被侵犯,但不包括仅由于对贡献者版本的进一步修改而会被侵犯的权利。在本定义中,“控制”包括以符合本许可证要求的方式授予专利转许可的权利。
每个贡献者授予您非独占的、全球性的、免版税的专利许可,在贡献者的基本专利权利下,制作、使用、销售、要约销售、进口以及以其他方式运行、修改和传播其贡献者版本的内容。
在以下三段中,“专利许可”是指任何明示的协议或承诺,无论名称如何,不执行专利(如行使专利的明示许可或不就专利侵权提起诉讼的承诺)。向一方“授予”此类专利许可意味着达成此类协议或承诺,不对该方执行专利。
如果您传达涵盖作品,明知依赖于一份专利许可,而该作品的对应源代码无法通过公开可用的网络服务器或其他易于访问的方式供任何人根据本许可证的条款免费复制,那么您必须:(1) 使对应源代码可以这样获得,或 (2) 安排剥夺您自己从该特定作品的专利许可中获得的利益,或 (3) 以符合本许可证要求的方式,安排将专利许可延伸到下游接受者。“明知依赖”意味着您实际知悉,如果不是因为有专利许可,您在某个国家传达涵盖作品,或您的接受者在某个国家使用涵盖作品,就会侵犯该国家的一项或多项您有理由相信是有效的可识别专利。
如果根据单一交易或安排,或与之相关,您传达或通过促成传达来传播涵盖作品,并向接收涵盖作品的某些方授予专利许可,授权他们使用、传播、修改或传达涵盖作品的特定副本,那么您授予的专利许可将自动延伸到涵盖作品的所有接受者及基于它的作品。
如果专利许可的覆盖范围不包括本许可证专门授予的一项或多项权利、禁止行使这些权利、或以不行使这些权利为条件,则该专利许可被视为“歧视性”的。如果您与从事软件分发业务的第三方达成安排,根据该安排,您根据传达作品的活动程度向第三方支付费用,且第三方根据该安排向从您处获得涵盖作品的任何方授予歧视性的专利许可,则您不得传达涵盖作品:(a) 与您传达的涵盖作品副本(或从这些副本制作的副本)相关,或 (b) 主要针对包含涵盖作品的特定产品或汇编,除非您在 2007 年 3 月 28 日之前进入该安排或该专利许可已被授予。
本许可证中的任何内容都不应被解释为排除或限制根据适用专利法可能为您提供的任何默示许可或其他侵权抗辩。
12. 不得损害他人的自由.
如果强加给您的条件(无论是通过法院命令、协议还是其他方式)与本许可证的条件相抵触,它们并不能免除您遵守本许可证的条件。如果您无法在传达涵盖作品的同时满足本许可证下的义务和任何其他相关义务,那么您根本不得传达它。例如,如果您同意要求您从传达程序的人那里收取版税的条款,那么您能够同时满足这些条款和本许可证的唯一方法就是完全不传达程序。
13. 远程网络交互;与 GNU 通用公共许可证一起使用.
尽管本许可证有任何其他规定,如果您修改程序,您的修改版必须显著地向所有通过计算机网络与其远程交互的用户(如果您的版本支持此类交互)提供一个机会,通过某种标准的或惯常的方便复制软件的方式,从网络服务器免费获得您版本的对应源代码。该对应源代码应包括根据下一段包含的受 GNU 通用公共许可证第 3 版约束的任何作品的对应源代码。
尽管本许可证有任何其他规定,您有权将任何涵盖作品与根据 GNU 通用公共许可证第 3 版许可的作品链接或组合成单一的组合作品,并传达产生的作品。本许可证的条款将继续适用于涵盖作品的部分,但与其组合的作品将继续受 GNU 通用公共许可证第 3 版的约束。
14. 本许可证的修订版.
自由软件基金会可能会不时发布 GNU Affero 通用公共许可证的修订版和/或新版本。此类新版本在精神上将与当前版本相似,但在处理新问题或关注点时可能在细节上有所不同。
每个版本都有一个区分版本号。如果程序指定某个特定版本号的 GNU Affero 通用公共许可证“或任何更高版本”适用于它,您可以选择遵守该版本号或自由软件基金会发布的任何更高版本的条款和条件。如果程序没有指定 GNU Affero 通用公共许可证的版本号,您可以选择自由软件基金会曾发布的任何版本。
如果程序指定代理人可以决定将来可以使用哪个版本的 GNU Affero 通用公共许可证,则该代理人公开声明接受某个版本,即永久授权您为该程序选择该版本。
以后的许可证版本可能会给您额外的或不同的许可。但是,由于您选择遵循以后的版本,不会对任何作者或版权持有者施加额外的义务。
15. 免责声明.
在适用法律允许的范围内,对本程序不提供任何保证。除非另有书面说明,版权持有者和/或其他方“按原样”提供本程序,不提供任何形式的保证,无论是明示的还是暗示的,包括但不限于对适销性和特定用途适用性的暗示保证。关于程序的质量和性能的全部风险均由您承担。如果程序被证明有缺陷,您承担所有必要的维修、修复或纠正费用。
16. 责任限制.
除非适用法律要求或书面同意,否则任何版权持有者或任何其他根据上述许可修改和/或传达程序的方,都不对您的损害负责,包括因使用或无法使用程序而产生的任何一般的、特殊的、偶然的或间接的损害(包括但不限于数据丢失或数据变得不准确,或由您或第三方遭受的损失,或程序无法与任何其他程序一起运行),即使该持有者或其他方已被告知此类损害的可能性。
17. 第 15 条和第 16 条的解释.
如果上述免责声明和责任限制无法根据其条款产生当地法律效力,审查法院应适用最接近于绝对放弃与程序相关的所有民事责任的当地法律,除非收费随附程序的副本提供保证或承担责任。
条款与条件结束
如何将这些条款应用于您的新程序
如果您开发了一个新程序,并且您希望它能给公众带来最大的用处,实现这一点的最好方法是使其成为自由软件,每个人都可以在这些条款下重新分发和更改它。
为此,请在程序中附上以下通知。最安全的方法是将它们附在每个源文件的开头,以最有效地陈述保证的排除;每个文件至少应该有“版权”行和指向完整通知位置的指针。
<给出程序名称及其用途的简要说明的一行内容。>
版权所有 (C) <年份> <作者姓名>
本程序是自由软件:您可以根据自由软件基金会发布的 GNU Affero 通用公共许可证的条款(许可证的第 3 版,或(由您选择)任何更高版本)重新分发和/或修改它。
发布本程序的目的是希望它有用,但不提供任何保证;甚至没有适销性或特定用途适用性的暗示保证。有关详细信息,请参阅 GNU Affero 通用公共许可证。
您应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本。如果没有,请参阅 <https://www.gnu.org/licenses/>。
还请添加如何通过电子邮件和信函与您联系的信息。
如果您的软件可以通过计算机网络与用户进行远程交互,您还应该确保它为用户提供了一种获取其源代码的方法。例如,如果您的程序是一个 Web 应用程序,其界面可以显示一个指向代码存档的“源代码”链接。提供源代码的方法有很多,不同的程序会有不同的解决方案;具体要求见第 13 条。
如果有必要,您还应该让您的雇主(如果您是程序员)或学校(如果有的话)为程序签署一份“版权免责声明”。有关这方面的更多信息,以及如何应用和遵循 GNU AGPL,请参阅 <https://www.gnu.org/licenses/>。
---
*注:这是 GNU Affero 通用公共许可证 (AGPL) 的非官方中文翻译。它不是由自由软件基金会发布的,也不具有法律效力。只有英文原文才具有法律效力。*
+134
View File
@@ -0,0 +1,134 @@
# Hua.Todo 跨平台代办管理应用 v1.2.8
一个基于 WebView 容器(MAUI / Avalonia+ 嵌入式 ASP.NET Core WebServer 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux 平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI / Avalonia + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux。
- **任务管理**:支持创建、编辑、删除、完成状态切换、子任务管理。
- **云同步 (CloudSync)**:支持手动配置服务端地址并登录后拉取云端任务,支持安全策略(SecurityPolicy)配置。
- **关键词检索**:支持按任务标题实时过滤,支持 Esc 清空,大小写不敏感。
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持 DateTime 兼容性解析。
- **动态 API**:后端自动生成 RESTful API 并集成 Swagger UI,便于联调与调试。
## 📦 安装与使用
### 环境要求
- **后端**
- .NET 10 SDK
- Visual Studio 2022 或更高版本
- **前端**
- Node.js 18+
- npm 或 yarn
### 快速开始
#### 1. 克隆或下载项目
```bash
git clone <仓库地址>
cd Hua.Todo
```
#### 2. 启动后端 API
```bash
cd src/Hua.Todo.Host
dotnet restore
dotnet run
```
API 将在 `http://localhost:5173` 启动
开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`
#### 3. 启动前端 Web
```bash
cd src/Hua.Todo.Web
npm install
npm run dev
```
前端将在 `http://localhost:5174` 启动,并自动代理 `/api` 请求到 `http://localhost:5173`
#### 4. 启动 MAUI 客户端(Windows 三件套开发)
- 推荐:在 Visual Studio 中将启动项目设置为 `Hua.Todo.Maui`,并确保 `Hua.Todo.Host(5173)``Hua.Todo.Web(5174)` 已启动,然后按 F5 运行。
- 也可使用脚本一键拉起 Host + Vite(并可选启动 MAUI):
```powershell
.\start-dev.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` 提供静态文件)
### Linux 交付产物(v1.2.0
- `.tar.gz` 发布脚本:`publish-linux.ps1`(或使用 `publish.ps1 -Linux`
- Flatpak 基础结构(manifest/desktop entry/AppStream):`pack/linux/`
### 使用说明
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
- **完成任务**:点击任务前的复选框切换完成状态
- **删除任务**:点击删除按钮移除任务
## 🔧 开发指南
### 项目结构
```
Hua.Todo/
├── pack/ # 打包与交付产物(Linux/安装包等)
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ └── project/ # 项目进度/需求文档
├── src/ # 源代码目录
│ ├── Hua.Todo.Core/ # 领域实体与基础接口
│ ├── Hua.Todo.Application/ # 业务逻辑与应用层实现
│ ├── Hua.Todo.Host/ # 后端 API 宿主项目 (Kestrel)
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js 3 + Vite)
│ ├── Hua.Todo.Maui/ # 跨平台客户端项目 (Windows/Android/iOS/macOS)
│ ├── Hua.Todo.Avalonia/ # 桌面客户端项目 (Windows/macOS/Linux)
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档
```
### API 端点
- `GET /api/task` - 获取任务列表(默认:全部)
- `GET /api/task/active` - 获取未完成任务
- `GET /api/task/completed` - 获取已完成任务
- `GET /api/task/{id}` - 获取单个任务
- `POST /api/task` - 创建任务
- `PUT /api/task` - 更新任务(通过 Body 内的 id 定位)
- `PATCH /api/task/{id}/toggle` - 切换完成状态
- `DELETE /api/task/{id}` - 删除任务
- `GET /api/task/{parentTaskId}/subtasks` - 获取子任务列表
## 🤝 交流与贡献
- **QQ 交流群**2167048911 (Hua.Todo 交流群)
- **项目地址**[Hua.Todo](https://git.we965.cn/Tools/Hua.Todo)
- **贡献指南**:欢迎提交 Pull Request,详见 [其他信息](docs/manual/其他信息.md)
## 📄 开源协议
本项目采用 **AGPL-3.0** 许可证。详细内容请参阅 [LICENSE](LICENSE) 文件。
## 📚 更多文档
### 用户与开发者手册
- [技术栈与模块说明](docs/manual/技术栈与模块.md)
- [版本更新历史](docs/manual/版本记录.md)
- [技术设计文档](docs/manual/技术设计文档.md)
- [代码规范文档](docs/manual/代码规范文档.md)
- [其他信息 (贡献、许可证、联系方式)](docs/manual/其他信息.md)
### 项目进度与需求
- [产品需求文档](docs/project/产品需求文档.md)
- [Android 离线排查计划](docs/project/Android_NotFound_排查计划.md)
- [实现对比文档](docs/project/实现对比文档.md)
---
**Hua.Todo** - 跨平台任务管理,让效率无处不在!
-956
View File
@@ -1,956 +0,0 @@
"DeployProject"
{
"VSVersion" = "3:800"
"ProjectType" = "8:{978C614F-708E-4E1A-B201-565925725DBA}"
"IsWebType" = "8:FALSE"
"ProjectName" = "8:Setup"
"LanguageId" = "3:2052"
"CodePage" = "3:936"
"UILanguageId" = "3:2052"
"SccProjectName" = "8:"
"SccLocalPath" = "8:"
"SccAuxPath" = "8:"
"SccProvider" = "8:"
"Hierarchy"
{
"Entry"
{
"MsmKey" = "8:_17BA67E2733E421586A690779AE10839"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_1E301C03308E4C0AADE54375ACCA27E9"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_34EE79020CA44431B5F09CF100F2B372"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_42AD4BACEFC94D43B5DE5DDF9177EFAD"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_4C87263B310A4D3F8CC1BD3265F5B929"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_65835E291AAA6A7D2371E2E621AF3DC9"
"OwnerKey" = "8:_17BA67E2733E421586A690779AE10839"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_6792D68F0F43409CADA40277C6FE4B43"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_D50F9EB4AAE7436FBAD775A446017958"
"OwnerKey" = "8:_UNDEFINED"
"MsmSig" = "8:_UNDEFINED"
}
"Entry"
{
"MsmKey" = "8:_UNDEFINED"
"OwnerKey" = "8:_17BA67E2733E421586A690779AE10839"
"MsmSig" = "8:_UNDEFINED"
}
}
"Configurations"
{
"Debug"
{
"DisplayName" = "8:Debug"
"IsDebugOnly" = "11:TRUE"
"IsReleaseOnly" = "11:FALSE"
"OutputFilename" = "8:Debug\\Setup.msi"
"PackageFilesAs" = "3:2"
"PackageFileSize" = "3:-2147483648"
"CabType" = "3:1"
"Compression" = "3:2"
"SignOutput" = "11:FALSE"
"CertificateFile" = "8:"
"PrivateKeyFile" = "8:"
"TimeStampServer" = "8:"
"InstallerBootstrapper" = "3:2"
}
"Release"
{
"DisplayName" = "8:Release"
"IsDebugOnly" = "11:FALSE"
"IsReleaseOnly" = "11:TRUE"
"OutputFilename" = "8:Release\\Setup.msi"
"PackageFilesAs" = "3:2"
"PackageFileSize" = "3:-2147483648"
"CabType" = "3:1"
"Compression" = "3:2"
"SignOutput" = "11:FALSE"
"CertificateFile" = "8:"
"PrivateKeyFile" = "8:"
"TimeStampServer" = "8:"
"InstallerBootstrapper" = "3:2"
"BootstrapperCfg:{63ACBE69-63AA-4F98-B2B6-99F9E24495F2}"
{
"Enabled" = "11:FALSE"
"PromptEnabled" = "11:TRUE"
"PrerequisitesLocation" = "2:1"
"Url" = "8:"
"ComponentsUrl" = "8:"
"Items"
{
"{EDC2488A-8267-493A-A98E-7D9C3B36CDF3}:.NETFramework,Version=v4.7.2"
{
"Name" = "8:Microsoft .NET Framework 4.7.2 (x86 和 x64)"
"ProductCode" = "8:.NETFramework,Version=v4.7.2"
}
}
}
}
}
"Deployable"
{
"CustomAction"
{
}
"DefaultFeature"
{
"Name" = "8:DefaultFeature"
"Title" = "8:"
"Description" = "8:"
}
"ExternalPersistence"
{
"LaunchCondition"
{
"{A06ECF26-33A3-4562-8140-9B0E340D4F24}:_3DB760AE720B4C9DB0261DBC0A13DD96"
{
"Name" = "8:.NET Framework"
"Message" = "8:[VSDNETMSG]"
"FrameworkVersion" = "8:.NETFramework,Version=v4.7.2"
"AllowLaterVersions" = "11:FALSE"
"InstallUrl" = "8:http://go.microsoft.com/fwlink/?LinkId=863262"
}
}
}
"File"
{
"{9F6F8455-1EF1-4B85-886A-4223BCC8E7F7}:_17BA67E2733E421586A690779AE10839"
{
"AssemblyRegister" = "3:1"
"AssemblyIsInGAC" = "11:FALSE"
"AssemblyAsmDisplayName" = "8:TodoList, Version=1.0.0.0, Culture=neutral, processorArchitecture=MSIL"
"ScatterAssemblies"
{
"_17BA67E2733E421586A690779AE10839"
{
"Name" = "8:TodoList.dll"
"Attributes" = "3:512"
}
}
"SourcePath" = "8:..\\TodoList\\bin\\Debug\\net10.0-windows\\TodoList.dll"
"TargetName" = "8:"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
"{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_1E301C03308E4C0AADE54375ACCA27E9"
{
"SourcePath" = "8:..\\TodoList\\bin\\Debug\\net10.0-windows\\TodoList.exe"
"TargetName" = "8:TodoList.exe"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
"{9F6F8455-1EF1-4B85-886A-4223BCC8E7F7}:_34EE79020CA44431B5F09CF100F2B372"
{
"AssemblyRegister" = "3:1"
"AssemblyIsInGAC" = "11:FALSE"
"AssemblyAsmDisplayName" = "8:CommunityToolkit.Mvvm, Version=8.4.0.0, Culture=neutral, PublicKeyToken=4aff67a105548ee2, processorArchitecture=MSIL"
"ScatterAssemblies"
{
"_34EE79020CA44431B5F09CF100F2B372"
{
"Name" = "8:CommunityToolkit.Mvvm.dll"
"Attributes" = "3:512"
}
}
"SourcePath" = "8:..\\TodoList\\bin\\Debug\\net10.0-windows\\CommunityToolkit.Mvvm.dll"
"TargetName" = "8:"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
"{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_42AD4BACEFC94D43B5DE5DDF9177EFAD"
{
"SourcePath" = "8:..\\TodoList\\bin\\Debug\\net10.0-windows\\TodoList.runtimeconfig.json"
"TargetName" = "8:TodoList.runtimeconfig.json"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
"{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_4C87263B310A4D3F8CC1BD3265F5B929"
{
"SourcePath" = "8:..\\TodoList\\bin\\Debug\\net10.0-windows\\TodoList.deps.json"
"TargetName" = "8:TodoList.deps.json"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
"{9F6F8455-1EF1-4B85-886A-4223BCC8E7F7}:_65835E291AAA6A7D2371E2E621AF3DC9"
{
"AssemblyRegister" = "3:1"
"AssemblyIsInGAC" = "11:FALSE"
"AssemblyAsmDisplayName" = "8:CommunityToolkit.Mvvm, Version=8.4.0.0, Culture=neutral, PublicKeyToken=4aff67a105548ee2, processorArchitecture=MSIL"
"ScatterAssemblies"
{
"_65835E291AAA6A7D2371E2E621AF3DC9"
{
"Name" = "8:CommunityToolkit.Mvvm.dll"
"Attributes" = "3:512"
}
}
"SourcePath" = "8:CommunityToolkit.Mvvm.dll"
"TargetName" = "8:"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:TRUE"
"IsolateTo" = "8:"
}
"{1FB2D0AE-D3B9-43D4-B9DD-F88EC61E35DE}:_D50F9EB4AAE7436FBAD775A446017958"
{
"SourcePath" = "8:..\\TodoList\\bin\\Debug\\net10.0-windows\\icon.ico"
"TargetName" = "8:icon.ico"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
}
}
"FileType"
{
}
"Folder"
{
"{1525181F-901A-416C-8A58-119130FE478E}:_4CF5CB48101445E3B47CD2485BDDA511"
{
"Name" = "8:#1916"
"AlwaysCreate" = "11:FALSE"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Property" = "8:DesktopFolder"
"Folders"
{
}
}
"{3C67513D-01DD-4637-8A68-80971EB9504F}:_8A20A7DDF24B44E8AC7BBB584966EEF1"
{
"DefaultLocation" = "8:[ProgramFilesFolder][Manufacturer]\\[ProductName]"
"Name" = "8:#1925"
"AlwaysCreate" = "11:FALSE"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Property" = "8:TARGETDIR"
"Folders"
{
}
}
"{1525181F-901A-416C-8A58-119130FE478E}:_BF5ADB1EDBF04FA0A47DA23313C521B9"
{
"Name" = "8:#1919"
"AlwaysCreate" = "11:FALSE"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Property" = "8:ProgramMenuFolder"
"Folders"
{
}
}
}
"LaunchCondition"
{
}
"Locator"
{
}
"MsiBootstrapper"
{
"LangId" = "3:2052"
"RequiresElevation" = "11:FALSE"
}
"Product"
{
"Name" = "8:Microsoft Visual Studio"
"ProductName" = "8:TodoList"
"ProductCode" = "8:{146611B1-AB9F-46C3-8FD4-EE7BCEC54C33}"
"PackageCode" = "8:{151EF683-7149-4D7A-9134-3092572B04CD}"
"UpgradeCode" = "8:{82F2F6EB-C24C-477B-81E6-43EDB0EE36B0}"
"AspNetVersion" = "8:4.0.30319.0"
"RestartWWWService" = "11:FALSE"
"RemovePreviousVersions" = "11:TRUE"
"DetectNewerInstalledVersion" = "11:TRUE"
"InstallAllUsers" = "11:TRUE"
"ProductVersion" = "8:1.0.0"
"Manufacturer" = "8:ShaoHua"
"ARPHELPTELEPHONE" = "8:"
"ARPHELPLINK" = "8:"
"Title" = "8:TodoList"
"Subject" = "8:"
"ARPCONTACT" = "8:ShaoHua"
"Keywords" = "8:"
"ARPCOMMENTS" = "8:"
"ARPURLINFOABOUT" = "8:"
"ARPPRODUCTICON" = "8:"
"ARPIconIndex" = "3:0"
"SearchPath" = "8:"
"UseSystemSearchPath" = "11:TRUE"
"TargetPlatform" = "3:0"
"PreBuildEvent" = "8:"
"PostBuildEvent" = "8:"
"RunPostBuildEvent" = "3:0"
}
"Registry"
{
"HKLM"
{
"Keys"
{
"{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_8522CD2219284FA48C170669A2A3D9B3"
{
"Name" = "8:Software"
"Condition" = "8:"
"AlwaysCreate" = "11:FALSE"
"DeleteAtUninstall" = "11:FALSE"
"Transitive" = "11:FALSE"
"Keys"
{
"{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_2553544E11294AD0ACF21B6F2D65909C"
{
"Name" = "8:[Manufacturer]"
"Condition" = "8:"
"AlwaysCreate" = "11:FALSE"
"DeleteAtUninstall" = "11:FALSE"
"Transitive" = "11:FALSE"
"Keys"
{
}
"Values"
{
}
}
}
"Values"
{
}
}
}
}
"HKCU"
{
"Keys"
{
"{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_BD961664BECD4769871995ED2576A507"
{
"Name" = "8:Software"
"Condition" = "8:"
"AlwaysCreate" = "11:FALSE"
"DeleteAtUninstall" = "11:FALSE"
"Transitive" = "11:FALSE"
"Keys"
{
"{60EA8692-D2D5-43EB-80DC-7906BF13D6EF}:_885FDEC4E0C3484CA36F1A96BEAD1D41"
{
"Name" = "8:[Manufacturer]"
"Condition" = "8:"
"AlwaysCreate" = "11:FALSE"
"DeleteAtUninstall" = "11:FALSE"
"Transitive" = "11:FALSE"
"Keys"
{
}
"Values"
{
}
}
}
"Values"
{
}
}
}
}
"HKCR"
{
"Keys"
{
}
}
"HKU"
{
"Keys"
{
}
}
"HKPU"
{
"Keys"
{
}
}
}
"Sequences"
{
}
"Shortcut"
{
"{970C0BB2-C7D0-45D7-ABFA-7EC378858BC0}:_0D031B70CF7542448AC7303C2ED6DC6A"
{
"Name" = "8:TodoList"
"Arguments" = "8:"
"Description" = "8:"
"ShowCmd" = "3:1"
"IconIndex" = "3:0"
"Transitive" = "11:FALSE"
"Target" = "8:_1E301C03308E4C0AADE54375ACCA27E9"
"Folder" = "8:_BF5ADB1EDBF04FA0A47DA23313C521B9"
"WorkingFolder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Icon" = "8:_D50F9EB4AAE7436FBAD775A446017958"
"Feature" = "8:"
}
"{970C0BB2-C7D0-45D7-ABFA-7EC378858BC0}:_AE6A93A941DD4EEDA219EAF034A157CC"
{
"Name" = "8:todoList主输出"
"Arguments" = "8:"
"Description" = "8:"
"ShowCmd" = "3:1"
"IconIndex" = "3:0"
"Transitive" = "11:FALSE"
"Target" = "8:_6792D68F0F43409CADA40277C6FE4B43"
"Folder" = "8:_BF5ADB1EDBF04FA0A47DA23313C521B9"
"WorkingFolder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Icon" = "8:_D50F9EB4AAE7436FBAD775A446017958"
"Feature" = "8:"
}
}
"UserInterface"
{
"{2479F3F5-0309-486D-8047-8187E2CE5BA0}:_1A5922191B3444D2ADBFAAAEE654AFBE"
{
"UseDynamicProperties" = "11:FALSE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdBasicDialogs.wim"
}
"{DF760B10-853B-4699-99F2-AFF7185B4A62}:_1A7F6DF07CA34F1290B53C77E9A24DCD"
{
"Name" = "8:#1901"
"Sequence" = "3:1"
"Attributes" = "3:2"
"Dialogs"
{
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_12C9A09546FF47E89C2F76E5857982C5"
{
"Sequence" = "3:100"
"DisplayName" = "8:进度"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdProgressDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
"ShowProgress"
{
"Name" = "8:ShowProgress"
"DisplayName" = "8:#1009"
"Description" = "8:#1109"
"Type" = "3:5"
"ContextData" = "8:1;True=1;False=0"
"Attributes" = "3:0"
"Setting" = "3:0"
"Value" = "3:1"
"DefaultValue" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
}
}
"{DF760B10-853B-4699-99F2-AFF7185B4A62}:_1BED33199298468C95ACE087FD867323"
{
"Name" = "8:#1902"
"Sequence" = "3:1"
"Attributes" = "3:3"
"Dialogs"
{
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_3F862D9E99F34183BC0479F9A6500399"
{
"Sequence" = "3:100"
"DisplayName" = "8:已完成"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdFinishedDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
"UpdateText"
{
"Name" = "8:UpdateText"
"DisplayName" = "8:#1058"
"Description" = "8:#1158"
"Type" = "3:15"
"ContextData" = "8:"
"Attributes" = "3:0"
"Setting" = "3:1"
"Value" = "8:#1258"
"DefaultValue" = "8:#1258"
"UsePlugInResources" = "11:TRUE"
}
}
}
}
}
"{DF760B10-853B-4699-99F2-AFF7185B4A62}:_2579AFB8C446463A9A8BCBDE6897AF55"
{
"Name" = "8:#1901"
"Sequence" = "3:2"
"Attributes" = "3:2"
"Dialogs"
{
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_1F08E1AAFAC14C4BA560722C839F686C"
{
"Sequence" = "3:100"
"DisplayName" = "8:进度"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdAdminProgressDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
"ShowProgress"
{
"Name" = "8:ShowProgress"
"DisplayName" = "8:#1009"
"Description" = "8:#1109"
"Type" = "3:5"
"ContextData" = "8:1;True=1;False=0"
"Attributes" = "3:0"
"Setting" = "3:0"
"Value" = "3:1"
"DefaultValue" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
}
}
"{DF760B10-853B-4699-99F2-AFF7185B4A62}:_669B474BCA014E7C9871D52674DC793E"
{
"Name" = "8:#1900"
"Sequence" = "3:1"
"Attributes" = "3:1"
"Dialogs"
{
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_0EFA62CCD2C843EAA044829F13217B57"
{
"Sequence" = "3:300"
"DisplayName" = "8:确认安装"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdConfirmDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_860D684F21184955A2394B2FAE3B85B3"
{
"Sequence" = "3:200"
"DisplayName" = "8:安装文件夹"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdFolderDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
"InstallAllUsersVisible"
{
"Name" = "8:InstallAllUsersVisible"
"DisplayName" = "8:#1059"
"Description" = "8:#1159"
"Type" = "3:5"
"ContextData" = "8:1;True=1;False=0"
"Attributes" = "3:0"
"Setting" = "3:0"
"Value" = "3:1"
"DefaultValue" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_F62A5F384AC4466DAA5560FD53102C9B"
{
"Sequence" = "3:100"
"DisplayName" = "8:欢迎使用"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdWelcomeDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
"CopyrightWarning"
{
"Name" = "8:CopyrightWarning"
"DisplayName" = "8:#1002"
"Description" = "8:#1102"
"Type" = "3:3"
"ContextData" = "8:"
"Attributes" = "3:0"
"Setting" = "3:1"
"Value" = "8:#1202"
"DefaultValue" = "8:#1202"
"UsePlugInResources" = "11:TRUE"
}
"Welcome"
{
"Name" = "8:Welcome"
"DisplayName" = "8:#1003"
"Description" = "8:#1103"
"Type" = "3:3"
"ContextData" = "8:"
"Attributes" = "3:0"
"Setting" = "3:1"
"Value" = "8:#1203"
"DefaultValue" = "8:#1203"
"UsePlugInResources" = "11:TRUE"
}
}
}
}
}
"{DF760B10-853B-4699-99F2-AFF7185B4A62}:_B97CCC8EE8F848C2A4276A2E58150A45"
{
"Name" = "8:#1902"
"Sequence" = "3:2"
"Attributes" = "3:3"
"Dialogs"
{
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_1CDE38F50B454FFA8F5C722E23D7165D"
{
"Sequence" = "3:100"
"DisplayName" = "8:已完成"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdAdminFinishedDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
}
}
"{2479F3F5-0309-486D-8047-8187E2CE5BA0}:_F85F3CEDA84E4C21AA1D2BEB630006E7"
{
"UseDynamicProperties" = "11:FALSE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdUserInterface.wim"
}
"{DF760B10-853B-4699-99F2-AFF7185B4A62}:_F879EDB3FFCC474E9059D6DC3BED5CBE"
{
"Name" = "8:#1900"
"Sequence" = "3:2"
"Attributes" = "3:1"
"Dialogs"
{
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_4384C55A823E4238A33E43C392ADB6C2"
{
"Sequence" = "3:200"
"DisplayName" = "8:安装文件夹"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdAdminFolderDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_4D730CD1E9A541A9A0357FD14F274504"
{
"Sequence" = "3:100"
"DisplayName" = "8:欢迎使用"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdAdminWelcomeDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
"CopyrightWarning"
{
"Name" = "8:CopyrightWarning"
"DisplayName" = "8:#1002"
"Description" = "8:#1102"
"Type" = "3:3"
"ContextData" = "8:"
"Attributes" = "3:0"
"Setting" = "3:1"
"Value" = "8:#1202"
"DefaultValue" = "8:#1202"
"UsePlugInResources" = "11:TRUE"
}
"Welcome"
{
"Name" = "8:Welcome"
"DisplayName" = "8:#1003"
"Description" = "8:#1103"
"Type" = "3:3"
"ContextData" = "8:"
"Attributes" = "3:0"
"Setting" = "3:1"
"Value" = "8:#1203"
"DefaultValue" = "8:#1203"
"UsePlugInResources" = "11:TRUE"
}
}
}
"{688940B3-5CA9-4162-8DEE-2993FA9D8CBC}:_88BC734F7F1D4494AEE5F11E9797CD6B"
{
"Sequence" = "3:300"
"DisplayName" = "8:确认安装"
"UseDynamicProperties" = "11:TRUE"
"IsDependency" = "11:FALSE"
"SourcePath" = "8:<VsdDialogDir>\\VsdAdminConfirmDlg.wid"
"Properties"
{
"BannerBitmap"
{
"Name" = "8:BannerBitmap"
"DisplayName" = "8:#1001"
"Description" = "8:#1101"
"Type" = "3:8"
"ContextData" = "8:Bitmap"
"Attributes" = "3:4"
"Setting" = "3:1"
"UsePlugInResources" = "11:TRUE"
}
}
}
}
}
}
"MergeModule"
{
}
"ProjectOutput"
{
"{5259A561-127C-4D43-A0A1-72F10C7B3BF8}:_6792D68F0F43409CADA40277C6FE4B43"
{
"SourcePath" = "8:..\\TodoList\\obj\\Debug\\net10.0-windows\\TodoList.dll"
"TargetName" = "8:"
"Tag" = "8:"
"Folder" = "8:_8A20A7DDF24B44E8AC7BBB584966EEF1"
"Condition" = "8:"
"Transitive" = "11:FALSE"
"Vital" = "11:TRUE"
"ReadOnly" = "11:FALSE"
"Hidden" = "11:FALSE"
"System" = "11:FALSE"
"Permanent" = "11:FALSE"
"SharedLegacy" = "11:FALSE"
"PackageAs" = "3:1"
"Register" = "3:1"
"Exclude" = "11:FALSE"
"IsDependency" = "11:FALSE"
"IsolateTo" = "8:"
"ProjectOutputGroupRegister" = "3:1"
"OutputConfiguration" = "8:"
"OutputGroupCanonicalName" = "8:Built"
"OutputProjectGuid" = "8:{6CD40A2D-545E-C111-D437-1BE9E9C2C98C}"
"ShowKeyOutput" = "11:TRUE"
"ExcludeFilters"
{
}
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="ARM64" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Project Path="Setup/Setup.vdproj" Type="Installer" />
<Project Path="TodoList/TodoList.csproj" />
</Solution>
-9
View File
@@ -1,9 +0,0 @@
<Application x:Class="TodoList.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:TodoList"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
</Application.Resources>
</Application>
-308
View File
@@ -1,308 +0,0 @@
using System;
using System.Threading;
using System.Windows;
using System.Windows.Interop;
using Microsoft.Win32;
using TodoList.Services;
using TodoList.ViewModels;
using TodoList.Views;
using System.Linq;
namespace TodoList
{
public partial class App : System.Windows.Application
{
private IDataService _dataService;
private GlobalShortcutService _shortcutService;
private MainWindow _mainWindow;
private SettingsService _settingsService;
private System.Windows.Forms.NotifyIcon _notifyIcon;
private Mutex _mutex;
private EventWaitHandle _eventWaitHandle;
private const string UniqueEventName = "Global\\TodoListApp_Event_v1";
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
public App()
{
// Disable hardware acceleration to prevent black screen issues
// Must be set before any UI is created
System.Windows.Media.RenderOptions.ProcessRenderMode = System.Windows.Interop.RenderMode.SoftwareOnly;
}
protected override void OnStartup(StartupEventArgs e)
{
// Ensure app doesn't shutdown when main window closes (we hide it)
this.ShutdownMode = ShutdownMode.OnExplicitShutdown;
const string appName = "Global\\TodoListApp_Unique_Mutex_v1";
bool createdNew;
_mutex = new Mutex(true, appName, out createdNew);
if (!createdNew)
{
// Signal the existing instance
try
{
using (var evt = EventWaitHandle.OpenExisting(UniqueEventName))
{
evt.Set();
}
}
catch
{
// Fallback to old method if event open fails
var hWnd = FindWindow(null, "待办事项");
if (hWnd != IntPtr.Zero)
{
ShowWindow(hWnd, 9); // SW_RESTORE
SetForegroundWindow(hWnd);
}
}
// Force exit to prevent second instance from running
Environment.Exit(0);
return;
}
// Create the event handle for this instance
_eventWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset, UniqueEventName);
// Start a thread to listen for signals
Thread thread = new Thread(() =>
{
while (true)
{
_eventWaitHandle.WaitOne();
this.Dispatcher.Invoke(() => ShowMainWindow());
}
});
thread.IsBackground = true;
thread.Start();
base.OnStartup(e);
// Configure Auto Start
ConfigureAutoStart();
// Create Desktop/StartMenu Shortcut if needed (optional feature)
// CreateShortcut();
this.DispatcherUnhandledException += App_DispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
try
{
_settingsService = new SettingsService();
_dataService = new FileDataService();
_shortcutService = new GlobalShortcutService();
var mainViewModel = new MainViewModel(_dataService, _settingsService);
_mainWindow = new MainWindow(mainViewModel);
_mainWindow.Loaded += MainWindow_Loaded;
// Initialize Tray Icon
InitializeTrayIcon();
// Check for silent mode
bool silent = e.Args.Contains("--silent") || e.Args.Contains("-s");
if (!silent)
{
_mainWindow.Show();
}
}
catch (Exception ex)
{
Log("Startup Error: " + ex.ToString());
System.Windows.MessageBox.Show("Startup Error: " + ex.Message);
}
}
private void ConfigureAutoStart()
{
try
{
var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
string cmd = $"\"{exePath}\" --silent";
// If running as dotnet tool, try to find the shim or stable entry point
// Usually %USERPROFILE%\.dotnet\tools\todo.exe
var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var toolShim = System.IO.Path.Combine(userProfile, ".dotnet", "tools", "todo.exe");
if (System.IO.File.Exists(toolShim))
{
// If the shim exists and we are likely running it (or just installed it), prefer the shim
// This handles updates better as the shim path stays constant.
// But we should verify if the current process IS related to it?
// Actually, if installed as tool, we definitely want to use the shim.
cmd = $"\"{toolShim}\" --silent";
}
var key = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run", true);
if (key != null)
{
var existing = key.GetValue("TodoListApp");
if (existing == null || existing.ToString() != cmd)
{
key.SetValue("TodoListApp", cmd);
}
key.Close();
}
}
catch (Exception ex)
{
Log("AutoStart Error: " + ex.Message);
}
}
private void InitializeTrayIcon()
{
_notifyIcon = new System.Windows.Forms.NotifyIcon();
// Try load icon from resource or file
try
{
// Load from embedded resource
var resourceUri = new Uri("pack://application:,,,/icon.ico");
var streamInfo = System.Windows.Application.GetResourceStream(resourceUri);
if (streamInfo != null)
{
using (var stream = streamInfo.Stream)
{
_notifyIcon.Icon = new System.Drawing.Icon(stream);
}
}
else
{
_notifyIcon.Icon = System.Drawing.SystemIcons.Application;
}
}
catch
{
_notifyIcon.Icon = System.Drawing.SystemIcons.Application;
}
_notifyIcon.Visible = true;
_notifyIcon.Text = "TodoList";
_notifyIcon.DoubleClick += (s, e) => ShowMainWindow();
var contextMenu = new System.Windows.Forms.ContextMenuStrip();
contextMenu.Items.Add("打开主界面", null, (s, e) => ShowMainWindow());
contextMenu.Items.Add("退出", null, (s, e) => ExitApplication());
_notifyIcon.ContextMenuStrip = contextMenu;
}
private void ShowMainWindow()
{
Log("ShowMainWindow called");
if (_mainWindow != null)
{
if (_mainWindow.WindowState == WindowState.Minimized)
{
_mainWindow.WindowState = WindowState.Normal;
}
_mainWindow.Show();
_mainWindow.Activate();
// Force foreground
var helper = new WindowInteropHelper(_mainWindow);
var handle = helper.Handle;
if (handle != IntPtr.Zero)
{
SetForegroundWindow(handle);
}
}
}
private void ExitApplication()
{
_notifyIcon?.Dispose();
_notifyIcon = null;
Shutdown();
}
private void App_DispatcherUnhandledException(object sender, System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
{
Log("Dispatcher Error: " + e.Exception.ToString());
System.Windows.MessageBox.Show("Dispatcher Error: " + e.Exception.Message);
e.Handled = true;
}
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
Log("Domain Error: " + e.ExceptionObject.ToString());
System.Windows.MessageBox.Show("Critical Error: " + e.ExceptionObject.ToString());
}
private void Log(string message)
{
try
{
System.IO.File.AppendAllText("error.log", DateTime.Now + ": " + message + Environment.NewLine);
}
catch { }
}
private bool _isHotkeyRegistered;
private void RegisterHotkey()
{
if (_isHotkeyRegistered || _shortcutService == null || _settingsService == null) return;
var helper = new WindowInteropHelper(_mainWindow);
var handle = helper.Handle;
if (handle != IntPtr.Zero)
{
var settings = _settingsService.Settings;
var mods = GlobalShortcutService.GetModifier(settings.ShortcutModifiers);
var key = GlobalShortcutService.GetKey(settings.ShortcutKey);
_shortcutService.Register(handle, OnHotKeyPressed, mods, key);
_isHotkeyRegistered = true;
// Subscribe to settings changes to update hotkey
_settingsService.Settings.PropertyChanged += (s, args) =>
{
if (args.PropertyName == nameof(AppSettings.ShortcutModifiers) ||
args.PropertyName == nameof(AppSettings.ShortcutKey))
{
var newMods = GlobalShortcutService.GetModifier(_settingsService.Settings.ShortcutModifiers);
var newKey = GlobalShortcutService.GetKey(_settingsService.Settings.ShortcutKey);
_shortcutService.UpdateShortcut(newMods, newKey);
}
};
}
}
private void OnHotKeyPressed()
{
Log("Hotkey pressed.");
ShowMainWindow();
}
protected override void OnExit(ExitEventArgs e)
{
_notifyIcon?.Dispose();
_shortcutService?.Dispose();
base.OnExit(e);
}
// We can hook into MainWindow's Loaded event to register hotkey
private void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
RegisterHotkey();
}
}
}
-10
View File
@@ -1,10 +0,0 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
@@ -1,31 +0,0 @@
using System;
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
using System.Windows.Data;
namespace TodoList.Converters
{
public class EnumDescriptionConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null) return string.Empty;
var type = value.GetType();
var name = Enum.GetName(type, value);
if (name == null) return value.ToString();
var field = type.GetField(name);
if (field == null) return value.ToString();
var attr = Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) as DescriptionAttribute;
return attr?.Description ?? value.ToString();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using System;
using System.ComponentModel;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace TodoList.Models
{
public enum TodoPriority
{
[Description("低")]
Low,
[Description("中")]
Medium,
[Description("高")]
High
}
public enum SyncStatus
{
Synced,
Pending,
Failed
}
public partial class TodoItem : ObservableObject
{
[ObservableProperty]
private string id = Guid.NewGuid().ToString();
[ObservableProperty]
private string content = string.Empty;
[ObservableProperty]
private bool isCompleted;
[ObservableProperty]
private TodoPriority priority = TodoPriority.Medium;
[ObservableProperty]
private DateTime createdAt = DateTime.Now;
[ObservableProperty]
private SyncStatus syncStatus = SyncStatus.Pending;
}
}
-70
View File
@@ -1,70 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using TodoList.Models;
namespace TodoList.Services
{
public class FileDataService : IDataService
{
private readonly string _filePath;
public FileDataService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var folder = Path.Combine(appData, "TodoListApp");
Directory.CreateDirectory(folder);
_filePath = Path.Combine(folder, "tasks.json");
}
public async Task<List<TodoItem>> LoadTasksAsync()
{
if (!File.Exists(_filePath))
{
return new List<TodoItem>();
}
try
{
using var stream = File.OpenRead(_filePath);
var items = await JsonSerializer.DeserializeAsync<List<TodoItem>>(stream);
return items ?? new List<TodoItem>();
}
catch
{
return new List<TodoItem>();
}
}
public async Task SaveTaskAsync(TodoItem task)
{
var tasks = await LoadTasksAsync();
var existing = tasks.Find(t => t.Id == task.Id);
if (existing != null)
{
tasks.Remove(existing);
}
tasks.Add(task);
await SaveAllAsync(tasks);
}
public async Task SaveAllAsync(List<TodoItem> tasks)
{
using var stream = File.Create(_filePath);
await JsonSerializer.SerializeAsync(stream, tasks, new JsonSerializerOptions { WriteIndented = true });
}
public async Task DeleteTaskAsync(string id)
{
var tasks = await LoadTasksAsync();
var existing = tasks.Find(t => t.Id == id);
if (existing != null)
{
tasks.Remove(existing);
await SaveAllAsync(tasks);
}
}
}
}
-127
View File
@@ -1,127 +0,0 @@
using System;
using System.Runtime.InteropServices;
using System.Windows.Input;
using System.Windows.Interop;
namespace TodoList.Services
{
public class GlobalShortcutService : IDisposable
{
private const int HOTKEY_ID = 9000;
public const uint MOD_ALT = 0x0001;
public const uint MOD_CONTROL = 0x0002;
public const uint MOD_SHIFT = 0x0004;
public const uint MOD_WIN = 0x0008;
[DllImport("user32.dll")]
private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
[DllImport("user32.dll")]
private static extern bool UnregisterHotKey(IntPtr hWnd, int id);
private IntPtr _windowHandle;
private HwndSource _source;
private Action _onHotKeyPressed;
private bool _isRegistered;
public void Register(IntPtr windowHandle, Action onHotKeyPressed, uint modifiers, uint key)
{
// If already registered, unregister first (to support updating)
if (_isRegistered)
{
UnregisterHotKey(_windowHandle, HOTKEY_ID);
_source?.RemoveHook(HwndHook);
_isRegistered = false;
}
_windowHandle = windowHandle;
_onHotKeyPressed = onHotKeyPressed;
_source = HwndSource.FromHwnd(_windowHandle);
if (_source == null) return; // Should not happen if handle is valid
_source.AddHook(HwndHook);
if (RegisterHotKey(_windowHandle, HOTKEY_ID, modifiers, key))
{
_isRegistered = true;
}
else
{
System.Diagnostics.Debug.WriteLine("Failed to register hotkey.");
}
}
public void UpdateShortcut(uint modifiers, uint key)
{
if (_windowHandle != IntPtr.Zero && _onHotKeyPressed != null)
{
// Re-register with new keys
Register(_windowHandle, _onHotKeyPressed, modifiers, key);
}
}
private IntPtr HwndHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
const int WM_HOTKEY = 0x0312;
if (msg == WM_HOTKEY)
{
if (wParam.ToInt32() == HOTKEY_ID)
{
_onHotKeyPressed?.Invoke();
handled = true;
}
}
return IntPtr.Zero;
}
public void Unregister()
{
if (_isRegistered)
{
_source?.RemoveHook(HwndHook);
UnregisterHotKey(_windowHandle, HOTKEY_ID);
_isRegistered = false;
}
}
public void Dispose()
{
Unregister();
}
public static uint GetModifier(string modifiers)
{
uint mod = 0;
if (string.IsNullOrEmpty(modifiers)) return mod;
var parts = modifiers.Split(',');
foreach (var part in parts)
{
var p = part.Trim();
if (p.Equals("Control", StringComparison.OrdinalIgnoreCase)) mod |= MOD_CONTROL;
if (p.Equals("Alt", StringComparison.OrdinalIgnoreCase)) mod |= MOD_ALT;
if (p.Equals("Shift", StringComparison.OrdinalIgnoreCase)) mod |= MOD_SHIFT;
if (p.Equals("Windows", StringComparison.OrdinalIgnoreCase)) mod |= MOD_WIN;
}
return mod;
}
public static uint GetKey(string key)
{
if (Enum.TryParse<Key>(key, out var k))
{
return (uint)KeyInterop.VirtualKeyFromKey(k);
}
// Fallback for simple letters if Key enum doesn't match directly (though it should for A-Z)
if (key.Length == 1)
{
char c = char.ToUpper(key[0]);
if (c >= 'A' && c <= 'Z') return (uint)c;
if (c >= '0' && c <= '9') return (uint)c;
}
return 0x41; // Default 'A'
}
}
}
-14
View File
@@ -1,14 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using TodoList.Models;
namespace TodoList.Services
{
public interface IDataService
{
Task<List<TodoItem>> LoadTasksAsync();
Task SaveTaskAsync(TodoItem task);
Task SaveAllAsync(List<TodoItem> tasks);
Task DeleteTaskAsync(string id);
}
}
-56
View File
@@ -1,56 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace TodoList.Services
{
public partial class AppSettings : ObservableObject
{
[ObservableProperty]
private string shortcutModifiers = "Control,Alt"; // Comma separated
[ObservableProperty]
private string shortcutKey = "A";
}
public class SettingsService
{
private readonly string _filePath;
public AppSettings Settings { get; private set; }
public SettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var folder = Path.Combine(appData, "TodoListApp");
Directory.CreateDirectory(folder);
_filePath = Path.Combine(folder, "settings.json");
Settings = LoadSettings();
}
private AppSettings LoadSettings()
{
if (!File.Exists(_filePath)) return new AppSettings();
try
{
var json = File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
public void SaveSettings()
{
try
{
var json = JsonSerializer.Serialize(Settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_filePath, json);
}
catch { }
}
}
}
-21
View File
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
<ItemGroup>
<Resource Include="icon.ico" />
</ItemGroup>
</Project>
-163
View File
@@ -1,163 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using TodoList.Models;
using TodoList.Services;
namespace TodoList.ViewModels
{
public partial class MainViewModel : ObservableObject, IRecipient<TaskAddedMessage>
{
private readonly IDataService _dataService;
private readonly SettingsService _settingsService;
[ObservableProperty]
private ObservableCollection<TodoItem> tasks = new();
[ObservableProperty]
private bool showCompleted = false;
[ObservableProperty]
private string newContent;
[ObservableProperty]
private TodoPriority newPriority = TodoPriority.Medium;
[ObservableProperty]
private bool isSettingsOpen;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullShortcut))]
private string shortcutKey;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullShortcut))]
private string shortcutModifiers;
public string FullShortcut
{
get
{
var mods = ShortcutModifiers?.Replace(",", " + ");
return string.IsNullOrEmpty(mods) ? ShortcutKey : $"{mods} + {ShortcutKey}";
}
}
public MainViewModel(IDataService dataService, SettingsService settingsService)
{
_dataService = dataService;
_settingsService = settingsService;
ShortcutKey = _settingsService.Settings.ShortcutKey;
ShortcutModifiers = _settingsService.Settings.ShortcutModifiers;
WeakReferenceMessenger.Default.Register(this);
LoadTasksCommand.Execute(null);
}
[RelayCommand]
private void OpenSettings()
{
IsSettingsOpen = true;
ShortcutKey = _settingsService.Settings.ShortcutKey;
ShortcutModifiers = _settingsService.Settings.ShortcutModifiers;
}
[RelayCommand]
private void CloseSettings()
{
IsSettingsOpen = false;
}
[RelayCommand]
private void SaveSettings()
{
if (!string.IsNullOrWhiteSpace(ShortcutKey))
{
_settingsService.Settings.ShortcutKey = ShortcutKey.ToUpper();
_settingsService.Settings.ShortcutModifiers = ShortcutModifiers;
_settingsService.SaveSettings();
}
IsSettingsOpen = false;
}
async partial void OnShowCompletedChanged(bool value)
{
await LoadTasksAsync();
}
[RelayCommand]
private async Task AddTaskAsync()
{
if (string.IsNullOrWhiteSpace(NewContent)) return;
var newTask = new TodoItem
{
Content = NewContent,
Priority = NewPriority,
IsCompleted = false,
SyncStatus = SyncStatus.Pending
};
await _dataService.SaveTaskAsync(newTask);
NewContent = string.Empty;
NewPriority = TodoPriority.Medium;
await LoadTasksAsync();
}
[RelayCommand]
private async Task LoadTasksAsync()
{
var allTasks = await _dataService.LoadTasksAsync();
var filtered = ShowCompleted
? allTasks
: allTasks.Where(t => !t.IsCompleted).ToList();
// Sort: Uncompleted first, then by priority (High -> Low), then date
var sorted = filtered
.OrderBy(t => t.IsCompleted)
.ThenByDescending(t => t.Priority)
.ThenByDescending(t => t.CreatedAt)
.ToList();
Tasks.Clear();
foreach (var t in sorted)
{
Tasks.Add(t);
}
}
[RelayCommand]
private async Task ToggleCompleteAsync(TodoItem item)
{
if (item == null) return;
// item.IsCompleted is already toggled by UI binding before this command if TwoWay binding
// But usually CheckBox command parameter is the item.
// Let's assume the binding updates the property.
item.SyncStatus = SyncStatus.Pending; // Mark as pending sync
await _dataService.SaveTaskAsync(item);
await LoadTasksAsync(); // Refresh list to apply filter
}
[RelayCommand]
private async Task DeleteAsync(TodoItem item)
{
if (item == null) return;
await _dataService.DeleteTaskAsync(item.Id);
Tasks.Remove(item);
}
public async void Receive(TaskAddedMessage message)
{
await LoadTasksAsync();
}
}
public class TaskAddedMessage
{
}
}
@@ -1,58 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using System;
using System.Threading.Tasks;
using TodoList.Models;
using TodoList.Services;
namespace TodoList.ViewModels
{
public partial class QuickEntryViewModel : ObservableObject
{
private readonly IDataService _dataService;
private Action _closeAction;
[ObservableProperty]
private string content;
[ObservableProperty]
private TodoPriority priority = TodoPriority.Medium;
public QuickEntryViewModel(IDataService dataService, Action closeAction)
{
_dataService = dataService;
_closeAction = closeAction;
}
[RelayCommand]
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(Content)) return;
var newTask = new TodoItem
{
Content = Content,
Priority = Priority,
IsCompleted = false,
SyncStatus = SyncStatus.Pending
};
await _dataService.SaveTaskAsync(newTask);
// Notify MainViewModel
WeakReferenceMessenger.Default.Send(new TaskAddedMessage());
// Reset and close
Content = string.Empty;
Priority = TodoPriority.Medium;
_closeAction?.Invoke();
}
[RelayCommand]
private void Cancel()
{
_closeAction?.Invoke();
}
}
}
-256
View File
@@ -1,256 +0,0 @@
<Window x:Class="TodoList.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TodoList.Views"
xmlns:models="clr-namespace:TodoList.Models"
xmlns:converters="clr-namespace:TodoList.Converters"
mc:Ignorable="d"
Title="待办事项" Height="600" Width="450"
Background="#F5F5F7"
Icon="/icon.ico"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<ObjectDataProvider x:Key="PriorityEnum" MethodName="GetValues"
ObjectType="{x:Type sys:Enum}"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<ObjectDataProvider.MethodParameters>
<x:Type TypeName="models:TodoPriority"/>
</ObjectDataProvider.MethodParameters>
</ObjectDataProvider>
<Style x:Key="ModernButton" TargetType="Button">
<Setter Property="Background" Value="#007AFF"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#0062CC"/>
</Trigger>
</Style.Triggers>
</Style>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<converters:EnumDescriptionConverter x:Key="EnumDescConverter"/>
</Window.Resources>
<!-- Force Rebuild Trigger -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- Toolbar -->
<RowDefinition Height="Auto"/> <!-- Input -->
<RowDefinition Height="*"/> <!-- List -->
</Grid.RowDefinitions>
<!-- Header / Toolbar -->
<Border Grid.Row="0" Background="White" Padding="15" Effect="{DynamicResource {x:Static DropShadowEffect.ShadowDepthProperty}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="我的待办" FontSize="24" FontWeight="Bold" Foreground="#333"/>
<Button Grid.Column="1" Content="设置快捷键"
Command="{Binding OpenSettingsCommand}"
Background="Transparent" Foreground="#007AFF" Margin="0,0,15,0">
<Button.Template>
<ControlTemplate TargetType="Button">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</ControlTemplate>
</Button.Template>
</Button>
<CheckBox Grid.Column="2" Content="显示已完成"
IsChecked="{Binding ShowCompleted}"
VerticalAlignment="Center" Foreground="#555"/>
</Grid>
</Border>
<!-- Input Area -->
<Border Grid.Row="1" Margin="15" Background="White" CornerRadius="8" Padding="10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Text="{Binding NewContent, UpdateSourceTrigger=PropertyChanged}"
FontSize="14" Padding="5" Margin="0,0,10,0" BorderThickness="0,0,0,1"
VerticalContentAlignment="Center"
Tag="添加新任务...">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Text" Value="">
<!-- Placeholder could be done with a visual brush or adornment, keeping it simple for now -->
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding AddTaskCommand}"/>
</TextBox.InputBindings>
</TextBox>
<ComboBox Grid.Column="1" ItemsSource="{Binding Source={StaticResource PriorityEnum}}"
SelectedItem="{Binding NewPriority}"
Width="80" Margin="0,0,10,0" VerticalContentAlignment="Center">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="2" Content="添加" Command="{Binding AddTaskCommand}" Style="{StaticResource ModernButton}"
VerticalAlignment="Center" Height="32" Width="80"/>
</Grid>
</Border>
<!-- Task List -->
<ListBox Grid.Row="2" ItemsSource="{Binding Tasks}"
HorizontalContentAlignment="Stretch"
Background="Transparent" BorderThickness="0"
Margin="15,0,15,15"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Background" Value="White"/>
<Setter Property="Margin" Value="0,0,0,10"/>
<Setter Property="Padding" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border Background="{TemplateBinding Background}" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<CheckBox IsChecked="{Binding IsCompleted}"
Command="{Binding DataContext.ToggleCompleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
VerticalAlignment="Center">
<CheckBox.LayoutTransform>
<ScaleTransform ScaleX="1.2" ScaleY="1.2"/>
</CheckBox.LayoutTransform>
</CheckBox>
<StackPanel Grid.Column="1" Margin="15,0">
<TextBlock Text="{Binding Content}" FontSize="16" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding IsCompleted}" Value="True">
<Setter Property="TextDecorations" Value="Strikethrough"/>
<Setter Property="Foreground" Value="#999"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsCompleted}" Value="False">
<Setter Property="Foreground" Value="#333"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Priority, Converter={StaticResource EnumDescConverter}}" FontSize="12" Foreground="#888" Margin="0,2,0,0"/>
</StackPanel>
<Button Grid.Column="3" Content="✕"
Command="{Binding DataContext.DeleteCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"
Width="24" Height="24"
Background="Transparent" Foreground="#FF3B30"
BorderThickness="0" FontSize="12" FontWeight="Bold"
Cursor="Hand">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border x:Name="border" Background="{TemplateBinding Background}" CornerRadius="12">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="border" Property="Background" Value="#1AFF3B30"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Button.Template>
</Button>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Settings Dialog Overlay -->
<Grid Grid.RowSpan="3" Background="#80000000" Visibility="{Binding IsSettingsOpen, Converter={StaticResource BoolToVis}}">
<Border Background="White" Width="300" Height="200" CornerRadius="10" VerticalAlignment="Center" HorizontalAlignment="Center" Padding="20">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="快捷键设置" FontSize="18" FontWeight="Bold" HorizontalAlignment="Center"/>
<StackPanel Grid.Row="1" VerticalAlignment="Center">
<TextBlock Text="当前支持组合键 (如 Alt+X, Ctrl+Alt+A)" Foreground="#666" Margin="0,0,0,5" FontSize="12" HorizontalAlignment="Center"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<TextBox x:Name="ShortcutBox"
Text="{Binding FullShortcut, Mode=OneWay}"
Width="200" FontSize="16"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
PreviewKeyDown="ShortcutBox_PreviewKeyDown"
CaretBrush="Transparent"
IsReadOnly="True"
Cursor="Hand"
Padding="5"/>
</StackPanel>
<TextBlock Text="点击上方框并按下快捷键" Foreground="#999" FontSize="12" HorizontalAlignment="Center" Margin="0,5,0,0"/>
</StackPanel>
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="取消" Command="{Binding CloseSettingsCommand}" Width="80" Margin="0,0,10,0"
Background="#EEE" Foreground="#333" Height="30" BorderThickness="0">
<Button.Template>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="5">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Button.Template>
</Button>
<Button Content="保存" Command="{Binding SaveSettingsCommand}" Width="80" Height="30" Style="{StaticResource ModernButton}"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</Grid>
</Window>
-62
View File
@@ -1,62 +0,0 @@
using System.Windows;
using System.Windows.Input;
using TodoList.ViewModels;
namespace TodoList.Views
{
public partial class MainWindow : Window
{
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
protected override void OnClosing(System.ComponentModel.CancelEventArgs e)
{
e.Cancel = true;
this.Hide();
// Verify if app shuts down? No, ShutdownMode is Explicit.
}
private void ShortcutBox_PreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
e.Handled = true;
// Ignore modifier keys alone being the "main" key
if (e.Key == Key.LeftCtrl || e.Key == Key.RightCtrl ||
e.Key == Key.LeftAlt || e.Key == Key.RightAlt ||
e.Key == Key.LeftShift || e.Key == Key.RightShift ||
e.Key == Key.LWin || e.Key == Key.RWin)
{
return;
}
var key = e.Key;
if (key == Key.System) key = e.SystemKey; // Handle Alt+Key
// Build modifier string
var modifiers = new System.Collections.Generic.List<string>();
if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control) modifiers.Add("Control");
if ((Keyboard.Modifiers & ModifierKeys.Alt) == ModifierKeys.Alt) modifiers.Add("Alt");
if ((Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift) modifiers.Add("Shift");
if ((Keyboard.Modifiers & ModifierKeys.Windows) == ModifierKeys.Windows) modifiers.Add("Windows");
// Map key to string
string keyStr = key.ToString();
// Simple mapping for letters/digits (A-Z, 0-9)
if (keyStr.Length == 2 && keyStr.StartsWith("D") && char.IsDigit(keyStr[1]))
{
keyStr = keyStr.Substring(1);
}
// Update ViewModel
if (DataContext is MainViewModel vm)
{
vm.ShortcutModifiers = string.Join(",", modifiers);
vm.ShortcutKey = keyStr;
}
}
}
}
-84
View File
@@ -1,84 +0,0 @@
<Window x:Class="TodoList.Views.QuickEntryWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:TodoList.Views"
xmlns:models="clr-namespace:TodoList.Models"
xmlns:converters="clr-namespace:TodoList.Converters"
mc:Ignorable="d"
Title="新建待办" Height="220" Width="400"
WindowStyle="None" ResizeMode="NoResize"
WindowStartupLocation="CenterScreen"
Topmost="True"
Background="White"
BorderBrush="#007AFF" BorderThickness="1">
<Window.Effect>
<DropShadowEffect BlurRadius="20" ShadowDepth="5" Opacity="0.3"/>
</Window.Effect>
<Window.Resources>
<ObjectDataProvider x:Key="PriorityEnum" MethodName="GetValues"
ObjectType="{x:Type sys:Enum}"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<ObjectDataProvider.MethodParameters>
<x:Type TypeName="models:TodoPriority"/>
</ObjectDataProvider.MethodParameters>
</ObjectDataProvider>
<converters:EnumDescriptionConverter x:Key="EnumDescConverter"/>
<Style TargetType="Button">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}" CornerRadius="5" BorderThickness="0">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="新建待办" FontSize="18" FontWeight="Bold" Foreground="#333"/>
<TextBox Grid.Row="1" Margin="0,15,0,15"
Text="{Binding Content, UpdateSourceTrigger=PropertyChanged}"
FontSize="14" Padding="8" BorderThickness="0,0,0,1"
x:Name="InputBox">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SaveCommand}"/>
<KeyBinding Key="Esc" Command="{Binding CancelCommand}"/>
</TextBox.InputBindings>
</TextBox>
<StackPanel Grid.Row="2" Orientation="Horizontal" VerticalAlignment="Top">
<TextBlock Text="优先级:" VerticalAlignment="Center" Margin="0,0,10,0" Foreground="#666"/>
<ComboBox ItemsSource="{Binding Source={StaticResource PriorityEnum}}"
SelectedItem="{Binding Priority}"
Width="100">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource EnumDescConverter}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="取消" Command="{Binding CancelCommand}" Margin="0,0,10,0" Width="80" Height="32"
Background="#F0F0F0" Foreground="#333"/>
<Button Content="保存" Command="{Binding SaveCommand}" Width="80" Height="32" IsDefault="True"
Background="#007AFF" Foreground="White"/>
</StackPanel>
</Grid>
</Window>
-23
View File
@@ -1,23 +0,0 @@
using System;
using System.Windows;
using TodoList.Services;
using TodoList.ViewModels;
namespace TodoList.Views
{
public partial class QuickEntryWindow : Window
{
public QuickEntryWindow(IDataService dataService)
{
InitializeComponent();
DataContext = new QuickEntryViewModel(dataService, () => this.Hide());
}
protected override void OnActivated(EventArgs e)
{
base.OnActivated(e);
InputBox.Focus();
InputBox.SelectAll();
}
}
}
+683
View File
@@ -0,0 +1,683 @@
# Hua.Todo 代码规范文档 v1.2.8
## 1. 概述
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
## 2. 通用规范
### 2.1 命名约定
- **使用有意义的名称**: 变量、函数、类名应清晰表达其用途
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
- **一致性**: 在整个项目中保持命名风格一致
### 2.2 注释规范 (强制)
- **公共 API 必须添加 XML 文档注释** (包括 `public` / `protected` 的类、接口、方法、属性)
- ** summary**:一句话说明用途
- ** param / returns**:关键参数/返回值说明
- ** 异常或副作用**:在 summary 中明确说明(例如会注册系统钩子/会启动后台服务)
- **复杂逻辑添加行内注释**
- **禁止在日志或注释中输出密钥、Token、用户隐私信息**
### 2.3 代码格式化
- **使用统一的代码格式化工具** (VS / IDE 默认格式化)
- **保持一致的缩进和空格**
- **每行代码不超过 120 字符**
- **文件末尾保留一个空行**
## 3. C# 代码规范
### 3.1 跨平台逻辑规范
- **禁止混写 `#if`**:禁止在同一文件内混写多个平台的大段 `#if` 实现。
- **优先使用 partial/接口**:应优先使用 `partial` 类、接口与平台目录分离。
- **说明平台差异**:平台分离后的公共入口处必须说明“平台差异在哪里、默认实现是什么、为什么这么做”。
### 3.2 异步/后台任务
- **必须说明启动时机、错误处理策略、是否需要 UI 线程、以及是否可并发/可重入**。
#### 类和接口
```csharp
// 类名使用 PascalCase
public class TaskService
{
}
// 接口名使用 PascalCase,以 I 开头
public interface ITaskService
{
}
```
#### 方法和属性
```csharp
// 方法名使用 PascalCase
public Task<List<Task>> GetTasksAsync()
{
}
// 属性名使用 PascalCase
public string Title { get; set; }
```
#### 变量和参数
```csharp
// 私有字段使用 _camelCase
private readonly ITaskRepository _taskRepository;
// 局部变量使用 camelCase
var taskList = await GetTasksAsync();
// 方法参数使用 camelCase
public void CreateTask(string title, TaskPriority priority)
{
}
```
#### 常量
```csharp
// 常量使用 PascalCase
public const int MaxTaskTitleLength = 200;
```
### 3.2 代码组织
#### 文件结构
```csharp
// 1. using 语句(按字母顺序)
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
// 2. 命名空间
namespace Hua.Todo.Api.Services;
// 3. XML 文档注释
/// <summary>
/// 任务服务实现
/// </summary>
public class TaskService : ITaskService
{
// 4. 私有字段
private readonly ITaskRepository _taskRepository;
// 5. 构造函数
public TaskService(ITaskRepository taskRepository)
{
_taskRepository = taskRepository;
}
// 6. 公共方法
public async Task<List<Task>> GetTasksAsync()
{
// 实现
}
// 7. 私有方法
private bool ValidateTask(Task task)
{
// 实现
}
}
```
#### 命名空间组织
- 每个文件只包含一个命名空间
- 命名空间结构应与目录结构一致
- 使用 `.` 分隔层级
### 3.3 编码规范
#### 异步编程
```csharp
// 异步方法应以 Async 结尾
public async Task<Task> GetTaskByIdAsync(int id)
{
return await _taskRepository.GetByIdAsync(id);
}
// 使用 await 而非 .Result 或 .Wait
var task = await GetTaskByIdAsync(id);
// 使用 ConfigureAwait(false) 在库代码中
public async Task<List<Task>> GetTasksAsync()
{
return await _taskRepository.GetAllAsync().ConfigureAwait(false);
}
```
#### 依赖注入
```csharp
// 优先使用构造函数注入
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
private readonly ILogger<TaskService> _logger;
public TaskService(ITaskRepository taskRepository, ILogger<TaskService> logger)
{
_taskRepository = taskRepository;
_logger = logger;
}
}
```
#### 异常处理
```csharp
// 使用具体的异常类型
public async Task<Task> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
{
throw new NotFoundException($"Task with id {id} not found");
}
return task;
}
// 使用 using 语句管理资源
using var context = new TodoDbContext();
```
#### LINQ 使用
```csharp
// 优先使用方法语法
var completedTasks = tasks.Where(t => t.IsCompleted).ToList();
// 复杂查询使用查询语法
var query = from task in tasks
where task.IsCompleted
orderby task.CreatedAt descending
select task;
```
### 3.4 文档注释
```csharp
/// <summary>
/// 获取指定 ID 的任务
/// </summary>
/// <param name="id">任务 ID</param>
/// <returns>任务对象</returns>
/// <exception cref="NotFoundException">当任务不存在时抛出</exception>
public async Task<Task> GetTaskByIdAsync(int id)
{
// 实现
}
```
## 4. JavaScript/TypeScript 代码规范
### 4.1 命名规范
#### 变量和函数
```typescript
// 变量使用 camelCase
const taskList = [];
let currentTask = null;
// 函数使用 camelCase
function getTasks() {
// 实现
}
// 常量使用 UPPER_SNAKE_CASE
const MAX_TASK_TITLE_LENGTH = 200;
```
#### 类和接口
```typescript
// 类名使用 PascalCase
class TaskService {
// 实现
}
// 接口名使用 PascalCase
interface Task {
id: number;
title: string;
}
// 类型别名使用 PascalCase
type TaskPriority = 'high' | 'medium' | 'low';
```
### 4.2 代码组织
#### 文件结构
```typescript
// 1. 导入语句
import { ref, computed } from 'vue';
import { useTaskStore } from '@/stores/tasks';
import type { Task } from '@/types/task';
// 2. 类型定义
interface TaskForm {
title: string;
priority: TaskPriority;
}
// 3. 常量定义
const DEFAULT_PRIORITY: TaskPriority = 'medium';
// 4. 组合式函数或组件
export function useTasks() {
// 实现
}
```
#### 模块导入
```typescript
// 优先使用 ES6 模块语法
import { ref } from 'vue';
import axios from 'axios';
// 导出使用具名导出
export function useTasks() {
// 实现
}
export default useTasks;
```
### 4.3 TypeScript 规范
#### 类型定义
```typescript
// 为所有函数参数和返回值添加类型
function getTaskById(id: number): Task | null {
// 实现
}
// 使用接口定义对象类型
interface Task {
id: number;
title: string;
priority: TaskPriority;
isCompleted: boolean;
createdAt: Date;
}
// 使用类型别名定义联合类型
type TaskPriority = 'high' | 'medium' | 'low';
// 使用泛型提高代码复用性
interface ApiResponse<T> {
data: T;
message: string;
}
```
#### 类型断言
```typescript
// 优先使用类型守卫而非类型断言
function isTask(obj: unknown): obj is Task {
return typeof obj === 'object' && obj !== null && 'id' in obj;
}
// 避免使用 as any
const task = response.data as Task; // 避免
```
### 4.4 异步编程
```typescript
// 使用 async/await 而非 Promise 链
async function getTasks(): Promise<Task[]> {
const response = await axios.get('/api/task');
return response.data;
}
// 错误处理
try {
const tasks = await getTasks();
} catch (error) {
console.error('Failed to fetch tasks:', error);
}
```
## 5. Vue.js 代码规范
### 5.1 组件命名
```vue
<!-- 组件名使用 PascalCase -->
<script setup lang="ts">
// 组件名应与文件名一致
</script>
<template>
<!-- 模板中使用 kebab-case -->
<task-item :task="task" />
</template>
```
### 5.2 组件结构
```vue
<template>
<!-- 1. 模板 -->
<div class="task-list">
<task-item
v-for="task in tasks"
:key="task.id"
:task="task"
/>
</div>
</template>
<script setup lang="ts">
// 2. 导入
import { ref, computed } from 'vue';
import { useTaskStore } from '@/stores/tasks';
import TaskItem from './TaskItem.vue';
// 3. Props 定义
interface Props {
filter: 'all' | 'active' | 'completed';
}
const props = withDefaults(defineProps<Props>(), {
filter: 'all'
});
// 4. Emits 定义
const emit = defineEmits<{
(e: 'task-created', task: Task): void;
}>();
// 5. 响应式状态
const taskStore = useTaskStore();
const tasks = computed(() => taskStore.filteredTasks(props.filter));
// 6. 方法
const handleCreateTask = async (title: string) => {
const task = await taskStore.createTask(title);
emit('task-created', task);
};
</script>
<style scoped>
/* 6. 样式 */
.task-list {
padding: 16px;
}
</style>
```
### 5.3 组合式函数规范
```typescript
// composables/useTasks.ts
import { ref, computed } from 'vue';
import { useTaskStore } from '@/stores/tasks';
export function useTasks() {
const taskStore = useTaskStore();
const loading = ref(false);
const error = ref<string | null>(null);
const tasks = computed(() => taskStore.tasks);
const completedTasks = computed(() => taskStore.completedTasks);
const fetchTasks = async () => {
loading.value = true;
error.value = null;
try {
await taskStore.fetchTasks();
} catch (err) {
error.value = 'Failed to fetch tasks';
console.error(err);
} finally {
loading.value = false;
}
};
return {
tasks,
completedTasks,
loading,
error,
fetchTasks
};
}
```
### 5.4 状态管理规范
```typescript
// stores/tasks.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Task } from '@/types/task';
export const useTaskStore = defineStore('tasks', () => {
// State
const tasks = ref<Task[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
// Getters
const activeTasks = computed(() =>
tasks.value.filter(task => !task.isCompleted)
);
const completedTasks = computed(() =>
tasks.value.filter(task => task.isCompleted)
);
// Actions
async function fetchTasks() {
loading.value = true;
try {
const response = await fetch('/api/task');
tasks.value = await response.json();
} catch (err) {
error.value = 'Failed to fetch tasks';
} finally {
loading.value = false;
}
}
return {
tasks,
loading,
error,
activeTasks,
completedTasks,
fetchTasks
};
});
```
## 6. API 设计规范
### 6.1 RESTful API 设计
```csharp
// 本项目的任务端点采用 Dynamic API 路由约定:/api/task(由中间件反射分发)
[HttpGet("task")]
public async Task<ActionResult<List<Task>>> GetTasks()
{
}
// 使用资源 ID
[HttpGet("task/{id}")]
public async Task<ActionResult<Task>> GetTask(int id)
{
}
// 使用 HTTP 方法表示操作
[HttpPost("task")]
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
{
}
[HttpPut("task")]
public async Task<ActionResult<Task>> UpdateTask(UpdateTaskDto dto)
{
}
[HttpDelete("task/{id}")]
public async Task<ActionResult> DeleteTask(int id)
{
}
```
### 6.2 响应格式
```csharp
// 统一的响应格式
public class ApiResponse<T>
{
public bool Success { get; set; }
public T Data { get; set; }
public string Message { get; set; }
public List<string> Errors { get; set; }
}
// 成功响应
return Ok(new ApiResponse<Task>
{
Success = true,
Data = task,
Message = "Task created successfully"
});
// 错误响应
return BadRequest(new ApiResponse<object>
{
Success = false,
Message = "Validation failed",
Errors = new List<string> { "Title is required" }
});
```
## 7. Git 提交规范
### 7.1 提交信息格式
```
<type>(<scope>): <subject>
<body>
<footer>
```
### 7.2 Type 类型
- `feat`: 新功能
- `fix`: 修复 bug
- `docs`: 文档更新
- `style`: 代码格式调整(不影响代码运行)
- `refactor`: 重构(既不是新功能也不是修复 bug)
- `perf`: 性能优化
- `test`: 测试相关
- `chore`: 构建过程或辅助工具的变动
### 7.3 示例
```
feat(api): add task completion endpoint
- Add PATCH /api/task/{id}/toggle endpoint
- Update task service to handle completion logic
- Add unit tests for completion functionality
Closes #123
```
## 8. 测试规范
### 8.1 单元测试
```csharp
// 测试类命名: ClassName + Tests
public class TaskServiceTests
{
[Fact]
public async Task GetTasksAsync_ReturnsAllTasks()
{
// Arrange
var mockRepository = new Mock<ITaskRepository>();
var service = new TaskService(mockRepository.Object);
// Act
var result = await service.GetTasksAsync();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
}
}
```
### 8.2 集成测试
```csharp
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public ApiIntegrationTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task GetTasks_ReturnsSuccessAndCorrectContentType()
{
// Act
var response = await _client.GetAsync("/api/task");
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
}
```
## 9. 代码审查清单
### 9.1 代码质量
- [ ] 代码符合项目规范
- [ ] 变量和函数命名清晰
- [ ] 没有重复代码
- [ ] 复杂逻辑有注释说明
- [ ] 没有硬编码的魔法数字
### 9.2 功能正确性
- [ ] 功能实现符合需求
- [ ] 边界条件已处理
- [ ] 错误处理完善
- [ ] 有相应的单元测试
### 9.3 性能和安全
- [ ] 没有性能问题
- [ ] 敏感数据已保护
- [ ] 输入验证完善
- [ ] 没有安全漏洞
## 10. 工具配置
### 10.1 C# 工具
- **代码格式化**: dotnet format
- **代码分析**: Roslyn Analyzers
- **代码风格**: .editorconfig
- **文档生成**: DocFX
### 10.2 JavaScript/TypeScript 工具
- **代码格式化**: Prettier
- **代码检查**: ESLint
- **类型检查**: TypeScript
- **代码风格**: .prettierrc
### 10.3 .editorconfig 示例
```ini
root = true
[*.cs]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,ts,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
```
+19
View File
@@ -0,0 +1,19 @@
# 其他信息
## 🤝 贡献指南
1. Fork 项目
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 打开 Pull Request
## 📄 许可证
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](LICENSE) (英文) 或 [LICENSE.zh-CN](LICENSE.zh-CN) (中文) 文件了解详情
## 📞 联系方式
- 项目作者:ShaoHua
- 项目地址:https://git.we965.cn/Tools/Hua.Todo
- QQ 交流群:2167048911 (Hua.Todo 交流群)
+46
View File
@@ -0,0 +1,46 @@
# 技术栈与模块说明
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 13 (基于 .NET 10)
- **框架**.NET 10 (Preview/Early Adopter)
- **UI 框架**MAUI(移动端/部分桌面) + Avalonia(桌面端)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API (支持动态 API 生成)
- **数据访问**Entity Framework Core 10.0
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript 5+
- **框架**Vue.js 3
- **构建工具**Vite 5+
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
## 🎯 核心模块说明
### Hua.Todo.Core
领域实体层,定义核心实体(TaskEntity)、安全策略实体(SecurityPolicyEntity)、枚举(TaskPriority)以及仓储接口(ITaskRepository)。
### Hua.Todo.Application
应用层实现,包含:
- **业务逻辑**TaskService 实现。
- **动态 API**:基于 Middleware 的动态 API 生成逻辑。
- **云同步 (CloudSync)**:包含 Auth 验证、同步服务、安全策略管理等。
- **数据访问**EF Core 数据库上下文(TodoDbContext)与迁移。
### Hua.Todo.Host
后端 API 宿主,作为独立 Server 运行时提供运行环境和配置。
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript + Vite,提供用户界面,通过 HTTP API 与后端通信。
### Hua.Todo.Maui
跨平台客户端项目,将 Web 内容嵌入到原生容器中,支持 Windows、Android、iOS 和 macOS。
### Hua.Todo.Avalonia
桌面客户端项目(Avalonia + WebView),用于提供 Linux/Windows/macOS 桌面形态;同样通过嵌入式 WebServer + WebView 承载前端 UI。
+414
View File
@@ -0,0 +1,414 @@
# Hua.Todo 技术设计文档 v1.1.0
## 1. 项目概述
本文档描述 Hua.Todo v1.1.0 的技术设计方案,包括项目文件目录结构、模块划分、技术选型和实现细节。
## 2. 技术栈
### 2.1 后端技术栈
- **开发语言**: C# 10
- **框架**: .NET 10
- **UI 框架**: MAUI (Multi-platform App UI) + Avalonia (Linux 支持)
- **Web 服务器**: Kestrel (ASP.NET Core 内置)
- **API 框架**: ASP.NET Core Web API
- **数据访问**: Entity Framework Core
- **数据库**: SQLite (本地存储)
- **日志**: Serilog
- **依赖注入**: Microsoft.Extensions.DependencyInjection
### 2.2 前端技术栈
- **开发语言**: JavaScript/TypeScript
- **框架**: Vue.js 3
- **构建工具**: Vite
- **HTTP 客户端**: Axios
- **状态管理**: Pinia
- **UI 组件库**: Element Plus / Vant (移动端)
- **CSS 预处理器**: SCSS
## 3. 项目目录结构
```
Hua.Todo/
├── docs/ # 文档目录
│ ├── manual/ # 用户/开发者手册
│ │ ├── 技术栈与模块.md
│ │ ├── 版本记录.md
│ │ ├── 技术设计文档.md(本文件)
│ │ ├── 代码规范文档.md
│ │ └── 部署文档.md
│ └── project/ # 项目进度/需求文档
│ ├── 产品需求文档.md
│ └── ...
├── src/ # 源代码目录
│ ├── Hua.Todo.Maui/ # MAUI 主项目(跨平台入口)
│ │ ├── Platforms/ # 平台特定代码
│ │ │ ├── Windows/ # Windows 平台代码
│ │ │ │ ├── App.xaml # Windows 应用入口
│ │ │ │ └── Services/ # Windows 平台服务
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── MacCatalyst/ # macOS 平台代码
│ │ │ │ ├── App.xaml
│ │ │ │ └── Services/
│ │ │ │ └── HotKeyService.cs
│ │ │ ├── Android/ # Android 平台代码
│ │ │ │ ├── MainActivity.cs
│ │ │ │ └── Services/
│ │ │ │ └── NotificationService.cs
│ │ │ └── iOS/ # iOS 平台代码
│ │ │ ├── AppDelegate.cs
│ │ │ └── Services/
│ │ │ └── NotificationService.cs
│ │ ├── Resources/ # 资源文件
│ │ │ ├── Images/ # 图片资源
│ │ │ ├── Styles/ # 样式资源
│ │ │ └── Fonts/ # 字体资源
│ │ ├── Controls/ # 自定义控件
│ │ │ └── WebViewContainer.xaml
│ │ ├── Services/ # 服务层
│ │ │ ├── IHotKeyService.cs
│ │ │ ├── IPlatformService.cs
│ │ │ └── AppLifecycleService.cs
│ │ ├── App.xaml # MAUI 应用入口
│ │ ├── App.xaml.cs
│ │ ├── MauiProgram.cs # MAUI 程序配置
│ │ └── Hua.Todo.Maui.csproj # MAUI 项目文件
│ │
│ ├── 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
│ │ │ └── SyncController.cs
│ │ ├── Models/ # 数据模型
│ │ │ ├── Task.cs
│ │ │ ├── TaskDto.cs
│ │ │ └── ApiResponse.cs
│ │ ├── Services/ # 业务服务
│ │ │ ├── ITaskService.cs
│ │ │ ├── TaskService.cs
│ │ │ ├── ISyncService.cs
│ │ │ └── SyncService.cs
│ │ ├── Data/ # 数据访问层
│ │ │ ├── TodoDbContext.cs
│ │ │ ├── Converters/ # 类型转换器
│ │ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Middleware/ # 中间件
│ │ │ └── ExceptionMiddleware.cs
│ │ ├── Extensions/ # 扩展方法
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Host.csproj # Host 项目文件
│ │
│ ├── Hua.Todo.Application/ # 应用层
│ │ ├── Data/ # 数据访问
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Converters/ # 类型转换器
│ │ │ └── LenientUtcDateTimeStringConverter.cs
│ │ └── Hua.Todo.Application.csproj # Application 项目文件
│ │
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/ # 实体类
│ │ │ ├── Task.cs
│ │ │ └── TaskPriority.cs
│ │ ├── Interfaces/ # 接口定义
│ │ │ ├── ITaskRepository.cs
│ │ │ └── IUnitOfWork.cs
│ │ ├── ValueObjects/ # 值对象
│ │ │ └── TaskTitle.cs
│ │ ├── Specifications/ # 规范模式
│ │ │ └── TaskSpecifications.cs
│ │ └── Hua.Todo.Core.csproj # Core 项目文件
│ │
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ │ └── favicon.svg # 网站图标
│ │ ├── src/ # 源代码
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts # HTTP 客户端配置
│ │ │ │ ├── tasks.ts # 任务相关 API
│ │ │ │ └── settings.ts # 设置相关 API
│ │ │ ├── assets/ # 资源文件
│ │ │ │ ├── images/
│ │ │ │ └── styles/
│ │ │ ├── components/ # Vue 组件
│ │ │ │ ├── TaskList.vue
│ │ │ │ ├── TaskItem.vue
│ │ │ │ ├── QuickEntry.vue
│ │ │ │ └── Settings.vue
│ │ │ ├── composables/ # 组合式函数
│ │ │ │ ├── useTasks.ts
│ │ │ │ └── useHotKey.ts
│ │ │ ├── stores/ # 状态管理 (Pinia)
│ │ │ │ ├── tasks.ts
│ │ │ │ └── settings.ts
│ │ │ ├── types/ # TypeScript 类型定义
│ │ │ │ ├── task.ts
│ │ │ │ └── api.ts
│ │ │ ├── utils/ # 工具函数
│ │ │ │ ├── date.ts
│ │ │ │ └── storage.ts
│ │ │ ├── App.vue # 根组件
│ │ │ └── main.ts # 应用入口
│ │ ├── package.json # 依赖配置
│ │ ├── vite.config.ts # Vite 配置
│ │ ├── tsconfig.json # TypeScript 配置
│ │ └── index.html # HTML 模板
│ │
│ └── Hua.Todo.Tests/ # 测试项目
│ ├── Unit/ # 单元测试
│ │ ├── Services/
│ │ │ └── TaskServiceTests.cs
│ │ └── Controllers/
│ │ └── TasksControllerTests.cs
│ ├── Integration/ # 集成测试
│ │ └── ApiIntegrationTests.cs
│ └── Hua.Todo.Tests.csproj
├── .gitignore # Git 忽略文件
├── Hua.Todo.sln # 解决方案文件
└── README.md # 项目说明文档
```
## 4. 模块设计
### 4.1 MAUI 主项目 (Hua.Todo.Maui)
**职责**:
- 应用程序入口和生命周期管理
- 平台特定功能封装
- WebView 容器管理
- 本地 HTTP 服务器启动
**调试与接口文档**
- Windows Debug 模式下,内嵌 WebServer 默认提供 Swagger UI`{HostUrl}/swagger`)与 OpenAPI JSON`{HostUrl}/swagger/v1/swagger.json`),用于本地接口联调。
**关键组件**:
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
- `App.xaml.cs`: 应用程序主入口
- `WebViewContainer`: 封装 WebView 控件
- 平台特定服务: 快捷键、通知等
### 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 接口
- 业务逻辑处理
- 数据访问和持久化
- 本地 HTTP 服务器托管
**接口文档**
- 开发环境(`ASPNETCORE_ENVIRONMENT=Development`)下提供 Swagger UI`http://localhost:5173/swagger`(或 `https://localhost:7175/swagger`)。
**关键组件**:
- `CloudSync`: 云同步 Minimal APIs`/auth``/tasks``/sync``/security`
- `DynamicApiMiddleware`: 任务管理等业务接口通过 Dynamic API`/api/{service}/...`)对外暴露
- `Data`: 数据访问层和数据库上下文
- `Program.cs`: API 服务器配置和启动
### 4.4 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
- 实现领域驱动设计模式
**关键组件**:
- `Entities`: 领域实体
- `Interfaces`: 业务接口定义
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.5 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
- HTTP API 调用
- 状态管理
**关键组件**:
- `components`: Vue 组件
- `api`: API 调用封装
- `stores`: 状态管理
- `composables`: 组合式函数
## 5. HTTP API 设计
### 5.1 API 基础配置
- **基础 URL(开发三件套 / Host 模式)**: `http://localhost:5173/api`(或 `https://localhost:7175/api`
- **基础 URLMAUI 内嵌模式)**: `{HostUrl}/api``HostUrl` 来自 `appsettings.json: WebServer.HostUrl`,默认 `http://localhost:5057`
- **数据格式**: JSON
- **认证方式**: 以实际端点为准(如云同步相关端点可能需要认证)
- **跨域配置**: 允许本地跨域请求
### 5.2 API 端点设计
#### 任务管理 API
```
GET /api/task # 获取任务列表(默认:全部)
GET /api/task/active # 获取未完成任务
GET /api/task/completed # 获取已完成任务
GET /api/task/{id} # 获取单个任务
POST /api/task # 创建任务
PUT /api/task # 更新任务(通过 Body 内的 id 定位)
DELETE /api/task/{id} # 删除任务
PATCH /api/task/{id}/toggle # 切换完成状态
GET /api/task/{parentTaskId}/subtasks # 获取子任务列表
```
#### 云同步 APIHost 模式)
```
POST /auth/bootstrap # 初始化管理员(仅首次)
POST /auth/login # 登录
POST /auth/step-up # 二次验证(提升权限)
GET /tasks/ # 获取云端任务(只读)
POST /sync/ # 推送/拉取合并同步
GET /security/policy # 获取安全策略
PUT /security/policy # 更新安全策略
```
## 6. 数据库设计
### 6.1 数据库表结构
#### Tasks 表
```sql
CREATE TABLE Tasks (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Title TEXT NOT NULL,
Priority INTEGER NOT NULL DEFAULT 0,
IsCompleted INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
#### Settings 表
```sql
CREATE TABLE Settings (
Key TEXT PRIMARY KEY,
Value TEXT NOT NULL,
UpdatedAt TEXT NOT NULL
);
```
### 6.2 数据访问策略
- 使用 Entity Framework Core 进行数据访问
- 采用 Repository 模式封装数据访问
- 支持 LINQ 查询和异步操作
- 数据库迁移管理
## 7. 通信机制
### 7.1 HTTP 通信流程
1. **C# 后端启动**: MAUI 应用启动时启动本地 Kestrel 服务器
2. **Vue 前端加载**: WebView 加载 Vue 应用
3. **API 调用**: Vue 通过 Axios 调用本地 HTTP API
4. **数据处理**: C# 后端处理请求并返回 JSON 数据
5. **界面更新**: Vue 接收响应并更新界面
### 7.2 错误处理
- 统一的错误响应格式
- 异常中间件捕获和处理
- 前端错误提示和重试机制
## 8. 部署和打包
### 8.1 开发环境
- **后端调试**: 使用 Visual Studio 调试 MAUI 应用
- **前端调试**: 使用 Vite 开发服务器
- **热重载**: 支持前后端热重载
### 8.2 生产构建
- **前端构建**: `npm run build` 生成静态文件
- **后端打包**: MAUI 发布各平台应用
- **静态文件嵌入**: 将前端静态文件嵌入到 MAUI 应用中
### 8.3 平台特定配置
- **Windows**: WebView2 运行时要求
- **macOS**: 代码签名和公证
- **移动端**: 应用商店发布配置
- **Linux**: .NET MAUI 无官方 Linux 目标,需要引入独立桌面宿主(例如基于 WebKitGTK 的方案)并处理运行时依赖与打包格式
## 9. 性能优化
### 9.1 前端优化
- 组件懒加载
- 虚拟滚动(长列表)
- 图片懒加载
- 缓存策略
### 9.2 后端优化
- 数据库查询优化
- 响应缓存
- 异步处理
- 连接池管理
## 10. 安全考虑
### 10.1 本地安全
- 本地服务器仅监听 localhost
- 防止外部访问
- 数据加密存储(可选)
### 10.2 数据安全
- 数据库文件权限控制
- 定期备份机制
- 敏感数据保护
## 11. 测试策略
### 11.1 单元测试
- 核心业务逻辑测试
- 服务层测试
- 工具函数测试
### 11.2 集成测试
- API 集成测试
- 数据库集成测试
- 前后端集成测试
### 11.3 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
+65
View File
@@ -0,0 +1,65 @@
# 版本更新历史
## 🔄 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
- v1.2.0 (规划中)Linux 支持与增强功能
### 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.02026-04-07
- **Linux 官方支持**:新增 `Hua.Todo.Avalonia` 项目,正式适配 Linux 平台,同时支持 Windows 和 macOS。
- **Avalonia 桌面交互**:增加托盘菜单(显示/退出)、关闭隐藏到托盘、Windows 全局热键唤起主窗口、热键配置本地持久化;并对齐 Avalonia 的 appsettings 默认值。
- **关键词检索**:主界面增加搜索框,按任务标题实时过滤;采用“命中即显示(含上下文)”策略;支持 Esc 清空;英文大小写不敏感。
- **云同步(基础可用)**:新增“云同步设置”弹窗,支持手动配置服务端地址(格式校验 + 保存时可达性/风险提示);登录成功后拉取云端任务并刷新主界面(v1.2.0 为只读展示);401/403 时会自动清会话并弹出登录入口。
- **MAUIWindows)内嵌 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/`
- **Windows 发布打包修复**Inno Setup 安装包文件名带版本号(Hua.Todo_Setup_vX.Y.Z.exe);安装后快捷方式/启动项指向 Hua.Todo.Maui.exe;发布产物强制 IsUsingStatic=true。
- **Windows WebView2 数据目录调整**MAUIUnpackaged)默认会在安装目录生成 `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` 代理配置。
- **文档与部署指南**:新增 `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` 等模块的路径与职责描述。
- **端口配置校准**:修正文档中关于前端与后端 API 的端口说明(5173/5174)。
- **PRD 校准**:移除 v1.2.0 PRD 中“本地迭代不支持”表述与“数据迁移(导入/导出)”小节。
- **PRD 校准**:移除 v1.2.0 PRD 中“云同步”需求。
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持 (Windows, macOS, Android, iOS)
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
- 实现子任务支持
### v1.2.0 规划内容 (即将推出)
- **Linux 官方支持**:正式适配 Linux 平台。
- **Linux 打包与交付**:新增 `.tar.gz` 发布脚本与 Flatpakmanifest/desktop entry/AppStream)基础结构。
- **关键词检索**:支持按任务标题关键词搜索。
- **标签系统**:引入多标签支持,提升任务组织效率。
- **暗色模式**:全平台适配暗色/深色主题。
- **数据导出导入(后续)**:支持 JSON 格式数据备份与迁移(延期到后续版本)。
+128
View File
@@ -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 RuntimeWindows)或 WebKitGTKLinux)。
- **同步连接失败**
1. 确认服务端 API 是否可达(访问 `/swagger` 验证)。
2. 确认客户端配置的服务端地址格式(需包含 `http://` 或 `https://`)。
- **权限问题**:在 Linux 下运行前,务必执行 `chmod +x` 给主程序执行权限。
@@ -0,0 +1,92 @@
# Android 端显示 “Not Found” 排查计划(Hua.Todo.Maui
## 目标
- 找出 Android 模拟器里只显示 `Not Found` 的根因(是 Web 资源缺失、内嵌 Web Server 路由/解析问题,还是 WebView 加载了错误地址)
- 给出可验证的修复方案,并确保修复后能在 Android 上正常加载前端页面
## 背景(当前实现快速定位)
- Android 使用自建 TCP HTTP Server,静态资源从 APK 的 `Assets/wwwroot/*` 读取:[MobileEmbeddedWebServerService](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs)
- WebView 默认加载内嵌服务器地址(`IsUsingStatic=true` 时):[MainPage.xaml.cs](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Views/MainPage.xaml.cs)
- Android 端静态文件找不到时返回纯文本 `Not Found`[HandleStaticAsync](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/MobileEmbeddedWebServerService.cs#L214-L255)
## 排查顺序(从“最可能 & 最省时间”到“深入原因”)
### 1) 确认 WebView 实际加载的 URL
- 在 Android Debug 输出里确认 WebView Source(期望是 `http://localhost:5057``http://localhost:5057/`
- 如果不是内嵌地址,检查 `appsettings.json``WebServer.IsUsingStatic``ForEndUrl` 配置:[appsettings.json](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/appsettings.json)
判定:
- 若加载的是内嵌地址 → 继续第 2 步
- 若加载的是外部地址(ForEndUrl)→ 重点查 ForEndUrl 对应服务是否启动/路由是否正确
### 2) 确认前端 dist 是否存在且可用于打包
- 检查 `src/Hua.Todo.Web/dist/index.html` 是否存在
- 如果不存在:在 `src/Hua.Todo.Web` 下执行 `npm ci` + `npm run build`,确保产物生成
判定:
- dist 不存在/为空 → “Not Found”高概率来自 Android 静态资源根本没被构建或没被打进 APK
### 3) 确认 Android APK 内是否真的包含 `Assets/wwwroot/index.html`
- 重点验证打包结果是否存在:
- `assets/wwwroot/index.html`
- `assets/wwwroot/assets/*`(至少有 js/css
- 项目里通过 MSBuild 目标把 `Hua.Todo.Web/dist` 映射为 AndroidAssetLink 到 `wwwroot/...`):[Hua.Todo.Maui.csproj](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Hua.Todo.Maui.csproj#L150-L175)
判定:
- APK 内没有 `wwwroot/index.html` → 修复构建/打包流程(第 6 步会给方案)
- APK 内有 `wwwroot/index.html` → 继续第 4 步
### 4) 记录 Android 内嵌服务器的“收到的请求 Path”与“找不到的 assetPath”
目的:判断是否是“请求行解析不兼容”或“路径格式异常”导致找不到资源。
-`ReadRequestAsync``HandleStaticAsync` 临时输出:
- requestLine / target / path
- 计算出的 assetPath
- TryOpenAsset 失败的 assetPath
高频根因候选:
- WebView 请求行使用 absolute-form(例如 `GET http://localhost:5057/ HTTP/1.1`),当前解析逻辑会把整个 URL 当作 path,最终拼成无效 `wwwroothttp://...`,导致 404
### 5) 排除 WebView/网络限制类问题(只在必要时做)
- 如果看到的不是纯文本 `Not Found`,而是加载错误/空白:
- 检查 Android 明文 HTTP`http://localhost`)是否被允许
- 检查 `network_security_config.xml` 与 Manifest 配置:[network_security_config.xml](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/Resources/xml/network_security_config.xml)、[AndroidManifest.xml](file:///d:/Proj/Hua.Todo/src/Hua.Todo.Maui/Platforms/Android/AndroidManifest.xml)
### 6) 修复与验证(根据前面判定选择)
#### A. 资源缺失/未打包
- 让构建流程更“硬性”:
- 若 dist 不存在则强制构建,或在 Debug 也保证 `AndroidAsset` 包含 dist
- 可选:把 dist 复制进 `Hua.Todo.Maui/wwwroot` 再用 `<Content Include="wwwroot\**" />`/`<MauiAsset />` 统一打包(减少条件目标的不确定性)
验证:
- APK 内能看到 `assets/wwwroot/index.html`,启动后不再返回 `Not Found`
#### B. 请求路径解析不兼容(absolute-form 等)
- 改进 `ReadRequestAsync`:当 target 是 `http(s)://...` 时解析出其中的 Path + Query,再走现有逻辑
验证:
- 记录到的 path 变为 `/``/index.html`,能成功打开 `wwwroot/index.html`
#### C. 资源引用路径问题(js/css 请求 404)
- 检查 dist 中 `index.html``assets/*` 的引用路径是否与 AndroidAsset 的 Link 一致
- 若 Vite 输出含子目录(例如 `assets/chunks/...`),需要在 csproj 里用 `dist\**\*` 并保留 `%(RecursiveDir)`,避免扁平化导致引用断裂
验证:
- WebView 网络请求里 js/css 全部 200,页面正常渲染
## 本次排查的“最短闭环”
- 先确认 dist 是否存在 + APK 是否包含 `assets/wwwroot/index.html`
- 若存在仍 Not Found,再用日志确认 requestLine/target/path 是否被解析成异常值(absolute-form 是最高优先级怀疑点)
@@ -0,0 +1,49 @@
# Hua.Todo v1.2.0 任务拆分总览
本目录用于把 [产品需求文档-1.2.0.md](file:///d:/Proj/6.Hua.Todo/docs/project/产品需求文档-1.2.0.md) 拆解为可落地、可并行推进的子任务。各任务文件之间尽量解耦;存在明确依赖时会在任务内标注。
## 目标(来自 PRD
- Linux 平台官方支持:MAUI 入口保持不动,Linux 新增 Avalonia 入口;继续用 WebView 承载同一套 Vue 前端;复用既有“本地 API ↔ 前端”的交互协议
- 任务检索(Search):主界面顶部新增搜索框,按任务标题模糊匹配
- 云同步(基础可用):用户手动配置服务端地址;RBAC + 二次认证;用户级数据隔离;服务端配置驱动的“可控落盘”
## 当前实现基线(用于任务定位)
- MAUI 启动与内嵌 WebServer`src/Hua.Todo.Maui`
- 前端(Vue)与 API client`src/Hua.Todo.Web`
- 后端宿主(ASP.NET,动态 API):`src/Hua.Todo.Host` + `src/Hua.Todo.Application/DynamicApi`
- 现有 WebView ↔ 前端注入协议(示例):`window.__API_BASE_URL__``window.mauiInterop`(用于 JS 侧拿到 API base 与若干事件桥接)
## 任务流(并行建议)
- **Linux 入口线**`01-*` + `02-*`
- 01:新增 Avalonia 入口、选择 Linux 可用的 WebView 控件、复用现有前后端协议
- 02:Linux 打包/交付产物(已落地:`.tar.gz` 发布脚本 + Flatpak 基础结构;详见 `publish-linux.ps1``pack/linux/`
- **Search 线**`03-*`
- 主要在前端完成;与云同步/平台入口基本无耦合,可并行
- 03:已完成:主界面搜索框(按标题包含匹配;命中即显示含上下文;Esc 清空;英文大小写不敏感)
- **云同步线**`04-*` + `05-*` + `06-*`
- 04:服务端基础能力(登录、任务同步/读取、配置下发、用户隔离)
- 05:客户端配置与同步工作流(手动指定服务端地址、登录后拉取任务)
- 06:安全与落盘策略(RBAC、二次认证、可控落盘与“内存模式”)
- **文档与验收线**`07-*`
- 对齐文档与现实现状/接口;补齐验收步骤与自测清单
## 待验证表
| 子任务 | 实现状态 | 验证状态 | 备注 |
|---|---|---|---|
| 01 - Linux 入口 | 已完成 | 待验证 | 基于 Avalonia + WebView.Avalonia 实现 |
| 02 - Linux 打包/交付 | 已落地 | 待验证 | `.tar.gz` 发布脚本 + Flatpak 基础结构 |
| 03 - Search 关键词检索 | 已完成 | 待验证 | 搜索框 + 树状任务标题包含匹配 |
| 04 - 云同步 服务端基础能力 | 已完成 | 已验证 | API 契约与错误码已固化到 04 文档 |
| 05 - 云同步 客户端配置与同步 | 已完成 | 待验证 | 新增“云同步设置”弹窗:地址校验+保存探测、登录/登出、登录后拉取云端任务并在主界面只读展示 |
| 06 - 安全与落盘策略 | 未标注 | 待验证 | |
| 07 - 文档与验收 | 未标注 | 待验证 | |
## 交付判定(v1.2.0 Done Definition
- Linux:在基线发行版(建议 Ubuntu LTS)上可启动、可渲染前端、可调用本地 API;且有可安装/可运行的交付产物
- Search:可在主界面按标题实时过滤任务(含层级任务的展示策略清晰)
- 云同步(基础可用):可配置服务端地址;登录后可拉取该用户任务;服务端可下发“是否允许落盘”;客户端在禁止落盘时不产生本地持久化
@@ -0,0 +1,93 @@
# 01 - LinuxAvalonia 入口 + WebView 承载 Vue
## 目标
- 新增一个 **Linux 桌面端入口**Avalonia)作为 Hua.Todo 的 Linux 宿主
- 在 Linux 宿主中用 **WebView** 加载同一套 Vue 前端(与 MAUI 端共用前端构建产物与资源路径约定)
- 复用既有“前端 ↔ 本地 API”的交互方式(HTTP API + 现有注入变量/事件名),保证前端无需为 Linux 分叉
## 范围
- 新增/调整项目结构以容纳 Avalonia 入口(建议新项目:`src/Hua.Todo.Avalonia``src/Hua.Todo.Desktop.Avalonia`
- 在 Avalonia 中接入可在 Linux 运行的 WebView 控件(需要先调研与选型)
- 确保能正确加载:
- 内嵌 WebServer 的 `BaseUrl`
- 以及前端静态资源(开发态/生产态策略需明确)
## 依赖
- 依赖 `04-*`/`05-*` 的云同步工作不强依赖本任务,可并行
- 依赖前端已有构建产物或构建流程(至少能得到 `dist/` 或等效 `wwwroot/`
## 关键决策点(必须在实现前落定)
### 1) Avalonia/Linux 可用的 WebView 方案选型
候选方向(示例,最终以调研结果为准):
- WebKitGTK 系(Linux 依赖较重,但适配面广)
- Chromium 系(体积/依赖/许可成本需评估)
选型必须满足的最低能力:
- 基础导航与本地资源加载
- JS 执行/注入(用于对齐 `window.__API_BASE_URL__` 等契约)
- JS ↔ Native 双向通信(至少开发阶段可观测;可先只保证“HTTP API”路径通)
- 开发期可用 DevTools(至少能定位网络请求与控制台错误)
### 2) 资源加载策略(与 MAUI 对齐)
需要明确并实现两种模式:
- 开发态:Avalonia WebView 指向前端 dev server(例如 `http://localhost:5173`)或指向本地 WebServer
- 生产态:加载随应用交付的静态资源(`wwwroot`)并确保 SPA fallback 生效(访问非 `/assets/*` 的路由能回到 `index.html`
## 实现落地(已完成)
### 1) 项目结构
- 新增 Linux 桌面端入口项目:`src/Hua.Todo.Avalonia`
- 入口类型:Avalonia Desktop AppClassicDesktopLifetime
### 2) WebView 方案选型(已落定)
- 采用:`WebView.Avalonia` + `WebView.Avalonia.Desktop`
- Windows:依赖 WebView2 Runtime
- Linux:依赖 GTK + WebKitGTK(运行环境缺失时 WebView 会初始化失败)
### 3) 资源加载策略(与 MAUI 对齐)
- 生产态(默认):内嵌 WebServer 托管 `wwwroot` 静态资源,WebView 指向 `HostUrl`
- 开发态:将 `IsUsingStatic=false`WebView 指向 `ForEndUrl`(例如 Vite dev server
### 4) 前端契约对齐(与 MAUI 一致)
- 在 WebView 导航完成后注入:
- `window.__API_BASE_URL__ = "${HostUrl}/api"`
- `window.mauiInterop`(事件名/字段名与现有前端保持一致)
### 5) 本地 API 与数据库初始化
- 内嵌 WebServer 使用 Kestrel 启动并注册动态 API 与 Controllers
- 启动时执行数据库迁移(Migrate),并设置 SQLite WAL 模式以降低锁冲突风险
- 默认 SQLite 路径使用用户目录(`LocalApplicationData/Hua.Todo/Hua.Todo.db`),避免安装目录无写权限导致启动失败
## 实施步骤(建议)
1. 新建 Avalonia 宿主项目
- 提供 App/Window/MainView 基础结构
2. 接入 WebView 控件并完成最小加载闭环
- 能显示 `index.html`,能打开前端路由
3. 对齐与 MAUI 端一致的“前端契约”
- 注入 `window.__API_BASE_URL__`(值应为 `${BaseUrl}/api`
- 若沿用 `window.mauiInterop` 事件名,需在 Linux 端同名注入(建议命名逐步抽象为更通用的 `nativeInterop`,但 v1.2.0 以兼容为优先)
4. 复用本地 API(内嵌 WebServer
- 评估复用 `Hua.Todo.Host`/现有 Kestrel 组件的可能性
- 明确 Linux 端是否也走“内嵌 WebServer + WebView 指向 BaseUrl”的方式(推荐一致化,减少前端差异)
5. 输入法/字体/DPI 验证与可配置化
- 至少验证:中文输入法、缩放、Wayland/X11 下可用性
## 验收标准
- 在 Linux(建议 Ubuntu LTS)上启动 Avalonia 入口后:
- WebView 正常渲染前端主界面
- 前端能通过 `window.__API_BASE_URL__` 正确请求本地 `/api/*`(至少任务列表接口可用)
- 页面路由/刷新不会出现 404SPA fallback 生效)
- 开发态可调试(至少能看到控制台/网络错误,或能通过日志定位)
@@ -0,0 +1,41 @@
# 02 - Linux:打包、依赖与交付产物
## 目标
- 为 Linux 提供可安装/可分发的交付产物,并尽量做到“用户机器无需手动补大量依赖即可运行”
- 明确最低支持发行版范围(建议 Ubuntu LTS 作为基线)与依赖策略(WebView 运行时/字体/输入法等)
## 范围
- 构建脚本与产物输出
- 运行时依赖打包策略(尤其是 WebView 相关)
- 基础安装/运行说明(README / docs 更新)
## 依赖
- 依赖 `01-*`Avalonia 入口与 WebView 方案已定、并能在开发机运行)
## 交付形式(PRD 约束)
- 优先提供一种“自包含”官方安装方式(AppImage/Flatpak 二选一,建议先做一条跑通)
- 同时保留 `.deb``.tar.gz` 作为补充分发形式(以脚本产出为准)
## 实施步骤(建议)
1. 确定 Linux 发行版基线与运行时依赖清单
- 记录:最低 glibc、Wayland/X11、WebView 运行时依赖、字体包
2. 选择并落地一种自包含交付形式
- AppImage:偏“拎包即用”,对依赖捆绑要求高
- Flatpak:沙盒与运行时生态更成熟,但需要 manifest 与权限策略
3. 补充 `.deb``.tar.gz`
- `.deb`:适合 Ubuntu/Debian 系;需要 desktop entry、图标、依赖声明
- `.tar.gz`:最通用;需要启动脚本与依赖说明
4. 增加启动前自检(可选但推荐)
- 发现关键依赖缺失时给出清晰错误提示与修复建议
## 验收标准
- 产物可在“干净环境”(尽量接近用户机)安装/运行
- 启动后 WebView 能渲染前端,且本地 API 可用
- 文档中给出的安装/运行步骤可复现,且与产物一致
@@ -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` 两个安装包。
@@ -0,0 +1,45 @@
# 03 - Search:任务标题关键词检索
## 目标
- 在主界面顶部增加搜索框
- 支持按任务标题进行模糊匹配(实时过滤)
## 范围
- 前端 UI:输入框、清空按钮、键盘交互(Esc 清空、Enter 可选)
- 过滤逻辑:对“树状任务”给出明确策略
- 性能:任务量增大时仍保持可用(至少避免 O(n^2) 的明显退化)
## 依赖
- 不依赖 Linux/Avalonia 或云同步,可独立并行完成
## 过滤策略(需在实现时确定,并写入实现说明)
树状任务(父子任务)常见两种策略,任选其一并保持一致:
1. **命中即显示(含上下文)**
- 任意节点标题命中则显示该节点
- 若子任务命中,可同时显示其祖先链(便于理解层级)
2. **扁平化命中**
- 只显示命中的任务(可能丢失层级语义)
建议优先采用“命中即显示(含上下文)”,用户可更快定位。
## 实施步骤(建议)
1. 确定搜索框放置位置(主界面顶部)
2. 增加 `searchQuery` 状态与派生 `filteredTasks`
3. 将过滤逻辑接入现有列表渲染
4. 补充交互细节
- 输入时实时过滤
- 清空按钮
- Esc 清空、保持输入焦点(可选)
## 验收标准
- 输入关键字后,任务列表实时过滤,仅展示命中结果(符合上述策略)
- 清空后恢复原列表
- 对中英文与大小写的处理行为明确(至少:英文大小写不敏感;中文按包含匹配)
@@ -0,0 +1,147 @@
# 04 - 云同步(基础可用):服务端基础能力
## 目标(PRD 约束)
- 支持用户级任务数据同步/读取(用户隔离)
- 提供基于角色/权限的访问控制(RBAC)
- 对高风险操作支持二次认证(能力以服务端实现为准)
- 下发“可控落盘”配置(服务端配置驱动)
## 范围(建议最小闭环)
- 认证(登录/会话)
- 任务数据 API(按用户隔离)
- 配置下发 API(是否允许落盘、终端可信度/会话策略等)
- RBAC 最小落地(至少能区分“允许同步/禁止同步”或“只读/读写”)
- 二次认证最小落地(至少覆盖“启用/关闭同步、切换账号、调整落盘策略”等高风险操作的接口)
## 依赖
-`05-*`(客户端)可并行推进,但需要尽早冻结 API 契约(字段名/响应结构/错误码)
## 接口契约(需要在实现前先写清楚)
v1.2.0 已实现一套最小可用契约(用于 05/06 客户端对接时冻结字段名/错误码)。
### 通用约定
- Base URL:由客户端配置(例如 `https://{host}:{port}`
- 认证:`Authorization: Bearer {accessToken}`
- 错误响应(统一结构):
```json
{ "code": "ERROR_CODE", "message": "Human readable message." }
```
### 错误码
- `UNAUTHORIZED`:未登录或会话失效
- `FORBIDDEN`:权限不足(RBAC / 策略拒绝)
- `SECOND_FACTOR_REQUIRED`:需要二次认证(step-up
- `BAD_REQUEST`:请求参数不合法
- `NOT_FOUND`:资源不存在
### Auth
- 初始化管理员(仅当系统尚无云用户时允许一次):
- `POST /auth/bootstrap`
- Body`{ "userName": "admin", "password": "..." }`
- 200:成功;403:已初始化或不允许
- 登录:
- `POST /auth/login`
- Body`{ "userName": "admin", "password": "..." }`
- 200`{ accessToken, expiresAtUtc, userId, role, permissions[] }`
- 二次认证(step-up,v1.2.0 最小实现为“复用登录口令进行再认证”):
- `POST /auth/step-up`
- Header`Authorization: Bearer ...`
- Body`{ "password": "..." }`
- 200`{ stepUpExpiresAtUtc }`
### Tasks
- 获取当前用户任务全量:
- `GET /tasks`
- Header`Authorization: Bearer ...`
- 权限:`tasks:read`
- 200`CloudTaskItem[]`
### Sync
- 上传/同步(增删改后返回最新全量):
- `POST /sync`
- Header`Authorization: Bearer ...`
- 权限:`sync:write`
- 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`
- Body
```json
{
"upserts": [
{ "id": 1, "title": "A", "priority": 1, "isCompleted": false, "parentTaskId": null },
{ "id": null, "title": "New", "priority": 1, "isCompleted": false, "parentTaskId": null }
],
"deletes": [2, 3]
}
```
- 200
```json
{
"serverTimeUtc": "2026-04-06T17:39:30.0281279Z",
"tasks": [ /* CloudTaskItem[] */ ]
}
```
### Security Policy
- 获取安全策略(服务端下发):
- `GET /security/policy`
- Header`Authorization: Bearer ...`
- 权限:`policy:read`
- 200
```json
{
"allowPersist": true,
"allowSync": true,
"requireSecondFactorFor": ["sync:write", "policy:write"]
}
```
- 更新安全策略(高风险操作):
- `PUT /security/policy`
- Header`Authorization: Bearer ...`
- 权限:`policy:write`
- 二次认证:必需(否则返回 `SECOND_FACTOR_REQUIRED`
- Body`{ "allowPersist": false, "allowSync": false }`
## 关键实现点(建议)
详细实现方案请参考:[06.1-CloudSync-服务端安全设计方案.md](file:///d:/Proj/6.Hua.Todo/docs/project/v1.2.0-tasks/06.1-CloudSync-服务端安全设计方案.md)
1. 用户隔离
- 服务端所有读写必须绑定当前登录用户上下文
2. 权限模型(RBAC
- 定义最小角色:例如 `user``admin`
- 定义最小权限:例如 `tasks:read``tasks:write``sync:enable`
3. 二次认证
- 选择实现方式(示例):二次口令/一次性验证码(TOTP)/短信(若无能力则先实现“二次口令”)
- 能力不足时必须返回明确错误,使客户端可提示用户
4. 落盘策略下发
- 支持按用户或按会话/终端下发
- 默认策略建议为“允许落盘”,便于可用性;但需支持“禁止落盘”
## 验收标准
- 使用不同用户登录后获取任务,数据严格隔离
- 未授权角色/权限访问受限接口会被拒绝(错误响应可被客户端识别)
- 能返回安全策略配置(至少包含 `allowPersist`),并可通过配置切换行为
## 当前实现说明(v1.2.0
- 数据隔离:`Tasks` 表新增 `UserId` 外键,云端 API 的所有读写按当前会话用户隔离;本地模式使用固定的 `local` 用户 ID(不影响既有 Dynamic API)。
- RBAC:内置 `admin / user / readonly / nosync` 角色与权限映射;并叠加 `SecurityPolicies.AllowSync` 作为“是否允许同步写入”的策略开关。
- 二次认证:通过 `POST /auth/step-up` 将会话提升到 step-up 状态;`POST /sync``PUT /security/policy` 会强制要求 step-up。
@@ -0,0 +1,48 @@
# 05 - 云同步(基础可用):客户端配置与同步工作流
## 目标(PRD 约束)
- 首次启用同步时,用户需要**手动填写服务端地址**(例如 `https://example.com`),并允许后续在设置中修改
- 客户端对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息
- 用户登录成功后,从服务端获取该用户任务并更新到前端展示
## 范围(建议最小闭环)
- “同步设置”入口与界面(服务端地址、登录、同步开关)
- 地址校验与风险提示
- 登录后拉取任务并刷新 UI
- 与本地数据的关系(v1.2.0 建议先做到“服务端为准/或本地为准”的单一策略,避免引入复杂冲突解决)
## 依赖
- 依赖 `04-*` 提供可用的服务端接口(至少登录 + 获取任务 + 获取安全策略)
-`03-*`Search)互不影响,可并行
## 关键交互与状态
需要定义并贯穿实现的状态机(至少包含):
- 未配置服务端地址
- 已配置未登录
- 已登录(可同步)
- 同步中/同步失败/同步成功(含失败原因)
## 实施步骤(建议)
1. 增加“同步设置”UI
- 放置在主界面可发现位置(例如顶部右侧设置按钮/侧边栏)
2. 服务端地址配置
- 基础校验:`https?://`、host 合法性、尾部 `/` 处理规则
- 保存时探测:尝试请求服务端健康检查或登录端点(并提示证书异常/不可达等)
3. 登录与凭据存储策略(与 `06-*` 协同)
- 在允许落盘场景下可持久化 token/会话
- 在禁止落盘场景下仅保留内存会话(退出即失效)
4. 登录后拉取任务
- 拉取成功后更新前端任务列表
- 拉取失败给出可理解提示,并允许重试
## 验收标准
- 首次启用同步必须先配置服务端地址;地址非法时不可保存并提示原因
- 保存地址时能提示“不可达/证书异常”等风险信息(至少能区分:成功、失败、存在风险但可继续)
- 登录后能从服务端拉取任务并展示在主界面
@@ -0,0 +1,57 @@
# 06 - 云同步(基础可用):安全(RBAC/二次认证)与“可控落盘”
## 目标(PRD 约束)
- 高风险操作支持二次认证(例如启用/关闭同步、切换账号、调整“是否允许落盘”等)
- 客户端读取服务端安全配置,决定任务信息是否允许落盘
- 当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据
## 范围
- 客户端:落盘策略切换(持久化 ↔ 内存)、高风险操作二次确认/二次认证 UI
- 服务端:策略下发 + 二次认证挑战/校验接口(与 `04-*` 协作)
## 依赖
- 依赖 `04-*` 提供策略下发与二次认证能力(接口契约)
- 依赖 `06.1-*` 提供服务端底层安全实现(账户/密码/Token/策略存储)
- 依赖 `05-*` 的基础登录/同步 UI 入口
## 关键设计点(v1.2.0 必须明确)
### 1) “落盘”定义与边界
需要明确“哪些数据算落盘”,并在禁止落盘时全部避免:
- 任务数据(列表/详情)
- 同步状态(上次同步时间、待同步队列等)
- 登录凭据(token/refresh token/会话标识)
### 2) 本地存储抽象
建议把前端(或宿主)本地存储封装为可替换实现:
- 允许落盘:使用现有 localStorage/SQLite 等
- 禁止落盘:使用内存实现(刷新/退出即清空)
要求:
- 切换策略时行为可预期(例如从允许 → 禁止:立即清空已落盘数据并提示用户)
- 不在日志或 UI 中暴露敏感信息
### 3) 二次认证(挑战-响应)
建议采用“服务端驱动”的挑战流程:
- 客户端发起高风险操作
- 服务端返回“需要二次认证”的响应(包含 challenge 信息)
- 客户端弹出二次认证输入(例如二次口令/一次性验证码)
- 客户端携带证明再次提交
若服务端暂不支持二次认证,也需有“能力探测/降级提示”,避免客户端卡死。
## 验收标准
- 服务端返回 `allowPersist=false` 时:
- 客户端不会把任务数据与凭据写入任何持久化介质
- 退出应用后重新进入,任务数据为空(需重新登录/拉取)
- 对指定高风险操作:
- 无二次认证证明时被拒绝,并提示需要二次认证
- 提供正确二次认证后操作成功
@@ -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)机制。
@@ -0,0 +1,39 @@
# 07 - 文档同步与验收清单(v1.2.0)
## 目标
- 保证 v1.2.0 实现后,README 与 docs 中的说明与实际代码一致
- 为 Linux / Search / 云同步提供可复现的验收步骤
## 范围
- READMEFeatures、运行方式、API 说明(如涉及)
- docs:技术设计文档/技术栈与模块/版本记录(如涉及)
- 本目录任务文件:保持与实现同步(必要时更新验收口径与依赖关系)
## 必做同步点(结合当前仓库现状)
- **API 端点与项目结构**:对齐实际实现(避免文档仍描述不存在的项目或旧端点)
- **Linux 入口说明**:新增 Avalonia 入口的构建/运行/打包方式
- **云同步说明**:服务端地址配置、登录、落盘策略与安全机制(RBAC/二次认证)的用户可理解描述
- **版本记录**:在 `docs/版本记录.md` 增加 v1.2.0 非琐碎变更条目
## 验收清单(建议逐项勾选)
### Linux
- [x] Linux 上可启动并打开主界面
- [x] WebView 能渲染 Vue 前端且路由可用(刷新不 404)
- [x] 前端能成功请求本地 `/api/*` 并加载任务列表
- [x] 有至少一种自包含交付产物(AppImage/Flatpak 二选一)可运行(见 pack/linux/README.md
### Search
- [x] 主界面有搜索框
- [x] 输入关键字后列表实时过滤(层级策略符合 `03-*` 定义)
- [x] 清空后恢复原列表
### 云同步(基础可用)
- [x] 可手动配置服务端地址,并有基础校验与风险提示
- [x] 登录后能拉取该用户任务并展示
- [x] 服务端策略 `allowPersist=false` 时客户端不落盘,退出后数据不保留
- [x] 高风险操作触发二次认证(服务端支持时)
+161
View File
@@ -0,0 +1,161 @@
# Hua.Todo 产品需求文档 (PRD) v1.1.0
## 1. 项目概述
本项目是一个基于 MAUI + WebView 架构开发的跨平台代办管理应用 (Hua.Todo)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。v1.1.0 版本将实现跨平台支持,覆盖 Windows、macOS、Android、iOS 和 Linux(预览)平台。
## 2. 技术架构
### 2.1 整体架构
- **开发语言**: C# (后端) + Vue.js (前端)
- **UI 框架**: MAUI (Multi-platform App UI) + WebView
- **目标框架**: .NET 10
- **前端框架**: Vue.js
- **WebView 实现**:
- Windows: WebView2 (微软官方,完美兼容)
- macOS: WKWebView
- Android: WebView
- iOS: WKWebView
- Linux: WebView (预览)
### 2.2 支持平台
- **Windows**: 完整支持
- **macOS**: 完整支持
- **Android**: 完整支持
- **iOS**: 完整支持
- **Linux**: 预览支持
### 2.3 架构分层
```
┌─────────────────────────────────────┐
│ Vue.js 前端界面 │
│ (跨平台统一的用户界面) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ WebView 容器层 │
│ (WebView2/WKWebView/WebView) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ MAUI 原生层 │
│ (平台特定功能封装) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ C# 业务逻辑层 │
│ (数据处理、状态管理) │
└─────────────────────────────────────┘
```
## 3. 功能需求
### 3.1 核心功能:快速记录 (Quick Entry)
- **全局快捷键**:
- Windows: 允许用户注册/使用系统级全局快捷键(例如 `Ctrl + Alt + A`
- macOS: 支持系统级快捷键(例如 `Cmd + Option + A`
- 移动端: 通过通知快捷方式或小组件实现快速记录
- 支持在应用后台运行时响应快捷键
- **快速唤起**:
- 按下快捷键时,若应用最小化或隐藏,应立即弹出"新建任务"窗口或主界面
- 窗口弹出后,输入框应自动获取焦点,用户可直接打字
### 3.2 任务模型 (Task Model)
每个任务需包含以下核心字段:
1. **任务名称 (Title/Content)**: 任务的具体描述
2. **紧急程度 (Priority/Urgency)**:
- 用于区分任务优先级(如:高、中、低)
- 需在界面上有直观的视觉区分(如颜色标记)
3. **完成状态 (IsCompleted)**:
- 标记任务是否已完成
### 3.3 任务列表与视图 (Task List & View)
- **列表展示**: 展示当前所有未完成的任务
- **默认过滤**:
- 应用启动或刷新时,**默认隐藏已完成的任务**
- (可选) 提供"显示已完成任务"的切换开关以便查看历史记录
### 3.4 离线与同步 (Offline & Sync)
- **离线记录**: 支持完全离线使用,数据优先保存于本地
- **数据同步**: 在网络可用时(或特定时机),自动将本地数据同步到服务端(预留同步机制)
### 3.5 跨平台适配需求
- **响应式设计**: Vue.js 界面需适配不同平台的屏幕尺寸和分辨率
- **平台特定功能**:
- Windows: 利用 WebView2 特性,如文件访问、剪贴板等
- macOS: 遵循 macOS 设计规范
- 移动端: 支持触摸手势、通知等移动端特性
- **原生功能集成**:
- 通过 MAUI 调用平台原生 API
- WebView 与 C# 代码的双向通信
### 3.6 UI设计原则
- **紧凑设计**: UI界面需采用紧凑布局,减少不必要的留白和装饰元素,最大化信息展示空间
- **高信息密度**: 在有限的屏幕空间内展示更多任务信息,提高用户浏览效率
- **简洁高效**: 去除冗余的视觉元素,聚焦核心功能,让用户快速完成操作
- **紧凑列表**: 任务列表项应采用紧凑的行高和间距,支持在单屏内显示更多任务
- **精简控件**: 使用紧凑的按钮、图标和输入框,减少控件占用的空间
- **高效布局**: 采用合理的网格或弹性布局,充分利用屏幕空间,避免大面积空白
## 4. 非功能需求
- **性能**:
- 启动速度快,快捷键响应低延迟
- WebView 加载性能优化
- **持久化**:
- 任务数据需保存到本地(如 SQLite, JSON, 或 XML
- 保证关闭应用后数据不丢失
- **跨平台一致性**:
- 各平台功能体验保持一致
- UI 界面风格统一
- **兼容性**:
- Windows: WebView2 运行时要求
- macOS: 系统版本要求
- 移动端: Android/iOS 最低版本要求
## 5. 技术实现要点
### 5.1 WebView 通信机制
- **C# → Vue**: 通过 HTTP API 接口提供数据
- **Vue → C#:** 通过 HTTP 请求调用 C# 后端接口
- **数据序列化**: 使用 JSON 格式进行数据交换
- **本地服务器**: C# 后端启动本地 HTTP 服务器(如 Kestrel
- **跨域处理**: 配置 CORS 支持跨域请求
- **API 设计**: RESTful API 设计风格
### 5.2 平台特定实现
- **Windows**:
- 使用 `Microsoft.Web.WebView2.Wpf` 或 MAUI 的 WebView2 控件
- 全局快捷键使用 Windows API
- **macOS**:
- 使用 `WKWebView`
- 全局快捷键使用 AppKit API
- **移动端**:
- 使用平台原生 WebView
- 快速记录通过通知快捷方式实现
### 5.3 构建与部署
- **统一构建**: 使用 MAUI 单一项目构建多平台应用
- **平台特定配置**: 针对不同平台的配置文件和资源管理
- **CI/CD**: 支持多平台的自动化构建和发布流程
## 6. 版本规划
### 6.1 v1.1.0 目标
- [ ] 完成 MAUI + WebView 架构搭建
- [ ] 实现 Windows 平台支持(基于 WebView2
- [ ] 实现 macOS 平台支持
- [ ] 实现移动端基础支持
- [ ] Vue.js 前端界面开发
- [ ] C# 后端业务逻辑实现
- [ ] WebView 与 C# 通信机制实现
- [ ] 跨平台功能测试
### 6.2 后续版本
- **v1.2.0**: Linux 平台正式支持
- **v1.3.0**: 云同步功能完善
- **v1.4.0**: 高级功能(如标签、提醒等)
## 7. 风险与挑战
- **WebView 兼容性**: 不同平台 WebView 实现的差异可能导致兼容性问题
- **性能优化**: WebView 方案相比原生方案可能存在性能开销
- **开发复杂度**: 跨平台开发增加了测试和维护的复杂度
- **平台限制**: 某些平台对 WebView 功能的限制可能影响功能实现
+84
View File
@@ -0,0 +1,84 @@
# Hua.Todo 产品需求文档 (PRD) v1.2.0
## 1. 项目概述
在 v1.1.0 版本成功实现 MAUI + WebView 跨平台架构的基础上,v1.2.0 版本将以“**MAUI 入口保持不动 + Linux 新增 Avalonia 入口**”的方式落地 Linux 支持(继续使用 WebView 承载 Vue 前端),并在此基础上探索 Avalonia 对其他终端的支持情况;若验证效果良好,后续版本将推进各终端入口统一切换到 Avalonia。
## 2. 核心目标
- **完善平台覆盖**:正式支持 Linux 平台。
- **增强任务组织**:引入搜索。
- **云同步(基础可用)**:支持用户级任务数据同步与“可控落盘”(在不信任终端场景下可禁止落盘)。
## 3. 功能需求
### 3.1 平台支持:Linux 官方支持
- **客户端框架迁移**:保持原 MAUI 项目不动,实现 Avalonia 对 Linux 端支持,并探索 Avalonia 对 Android 和其他平台的支持。
- **终端入口策略(v1.2.0**
- **保持 MAUI 入口不动**Windows/macOS/iOS/Android 继续沿用现有 MAUI 入口与 WebView 方案,保证迭代稳定性。
- **Linux 使用 Avalonia 作为入口**:新增 Avalonia 桌面端宿主作为 Linux 入口,继续用 WebView 承载同一套 Vue 前端,并复用既有“本地 API ↔ 前端”的交互协议。
- **评估全端切换可行性**:在完成 Linux 入口落地后,评估 Avalonia 在 Windows/macOS(以及后续 Android 等)的稳定性、WebView 兼容性与打包成本;若效果良好,后续版本将各终端入口统一切换到 Avalonia。
- **适配性**:确保 WebView 在 Linux 环境下的正常渲染与交互。
- **打包部署**:提供 Linux 平台的打包脚本(如 .deb 或 .tar.gz)。
### 3.2 任务检索 (Search)
- **关键词搜索**:在主界面顶部增加搜索框,支持按任务标题模糊匹配。
### 3.3 云同步(基础可用)
#### 3.3.1 服务端地址配置(用户手动指定)
- **用户手动指定服务端地址**:首次启用同步时,用户需要手动填写服务端地址(例如 `https://example.com`),并允许后续在设置中修改。
- **地址有效性提示**:客户端需对地址格式做基础校验,并在保存时提示可达性/证书异常等风险信息。
#### 3.3.2 权限与二次认证(RBAC + 二次认证)
- **RBAC 权限模型**:服务端必须提供基于角色/权限的访问控制(Role-Based Access Control),以支持后续按功能、数据域进行授权。
- **二次认证**:对高风险操作(例如启用/关闭同步、切换账号、调整“是否允许落盘”等安全相关配置)应支持二次认证机制(例如二次口令/一次性验证码等),具体实现以服务端能力为准。
#### 3.3.3 基于用户的数据隔离
- **用户级数据隔离**:服务端必须按用户隔离任务数据,任何同步、检索与配置读取都必须绑定当前登录用户上下文。
- **最小权限原则**:客户端仅请求完成任务同步所需的最小权限范围,避免默认授予不必要的数据访问能力。
#### 3.3.4 同步工作流(登录后获取任务 + 可控落盘)
- **登录后拉取任务**:用户登录成功后,客户端从服务端获取该用户的任务信息并更新到前端展示。
- **服务端配置驱动的落盘策略**:客户端需读取服务端针对该用户(或该会话/终端)的安全配置,决定任务信息是否允许落盘到本地存储。
- **不信任终端禁止落盘**:当服务端标记“终端不可信/不允许落盘”时,客户端只能在内存中持有任务信息;退出应用后不保留任务数据。
## 4. 技术实现要点
### 4.1 Linux 适配
v1.1.0 起客户端使用 **MAUI WebView** 承载 Vue 前端;在 v1.2.0 中,保持现有 MAUI 入口不动,同时为 Linux 新增 **Avalonia 桌面端**入口,并继续以 WebView 承载同一套 Vue 前端。后续将基于 Linux 端落地经验评估 Avalonia 对 Windows/macOS(以及后续 Android 等)的覆盖与稳定性,满足条件后推进全端入口统一。
#### 4.1.1 Linux 解决方案(Avalonia 入口 + WebView 承载 Vue
- **入口与架构**Linux 端新增 Avalonia 桌面宿主作为应用入口;加载同一套 Vue 前端,并保持与现有 MAUI 端一致的“前端调用本地 API”协议与数据模型,确保业务逻辑与 UI 复用。
- **WebView 方案**:选用可在 Avalonia/Linux 运行的 WebView 控件承载前端(以 WebKitGTK/Chromium 系为候选实现),统一约束:支持基础导航/本地资源加载、JS ↔ Native 双向通信、DevTools(至少开发环境可用)。
- **发行版与依赖策略**:定义最低支持范围(例如 Ubuntu LTS 系列作为基线),并将 WebView 运行时依赖纳入打包交付(避免用户手动安装大量依赖导致不可用)。
- **输入法/字体/DPI**:提供默认 CJK 字体与渲染配置,验证 IME、缩放、Wayland/X11 关键组合;遇到环境差异时以“可配置 + 明确降级”保证可用性。
- **快捷键策略**:全局快捷键在 Linux 上采用“尽力而为”的可插拔实现;在 Wayland 等受限环境下自动降级为应用内快捷键,并在设置中提供可配置与提示。
- **交付形式**:优先提供一种“自包含”的交付产物(例如 AppImage/Flatpak 之一)作为官方安装方式,同时保留 `.deb/.tar.gz` 作为补充分发形式(以脚本产出为准)。
## 5. 版本规划
### 5.1 v1.2.0 目标
- [ ] Linux 平台完整支持与打包脚本。
- [ ] 搜索框(关键词检索)。
- [ ] 云同步(基础可用:用户手动配置服务端地址、RBAC + 二次认证、用户级数据隔离、服务端配置驱动的可控落盘)。
### 5.2 后续版本规划
- **v1.3.0**:数据导入导出(JSON)、统计图表(任务完成趋势、时间分配分析)。
- **v1.4.0**:高级过滤(优先级/时间段)与更丰富的视图(如看板/日历)。
## 6. 风险评估
- **Linux 碎片化**:不同发行版下 WebView 的依赖库可能不一致。
- **凭据与合规**:第三方服务鉴权方式差异大,且必须保证凭据安全存储与最小化权限。
- **终端可信度与数据落盘**:若需支持“不信任终端不落盘”,需要明确“可信度判定与配置下发”的服务端策略,并保证客户端在无落盘场景下的可用性与体验。
@@ -1,7 +1,7 @@
# TodoList 产品需求文档 (PRD)
# Hua.Todo 产品需求文档 (PRD)
## 1. 项目概述
本项目是一个基于 C# WPF (.NET 10) 开发的桌面待办事项管理应用 (TodoList)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。
本项目是一个基于 C# WPF (.NET 10) 开发的桌面代办管理应用 (Hua.Todo)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。
## 2. 技术架构
- **开发语言**: C#
+243
View File
@@ -0,0 +1,243 @@
# Hua.Todo 实现对比文档
## 项目概述
本项目是一个基于 MAUI + WebView 架构开发的跨平台代办管理应用。
## 实现进度
### ✅ 已实现功能
#### 1. 核心功能:快速记录 (Quick Entry)
-**全局快捷键**:
- Windows: 支持 `Alt + X` 快捷键唤起快速记录窗口
- 快捷键配置页面,可自定义快捷键组合
- 快捷键启用/禁用功能
-**快速记录窗口**:
- 独立的快速记录页面
- 任务标题输入
- 优先级选择(低/中/高)
- 保存/取消按钮
- 导航到快速记录页面的功能
#### 2. 任务模型 (Task Model)
-**任务名称 (Title/Content)**: 任务的具体描述
-**紧急程度 (Priority/Urgency)**:
- 用于区分任务优先级(如:高、中、低)
- 在界面上有直观的视觉区分(如颜色标记)
-**完成状态 (IsCompleted)**: 标记任务是否已完成
-**父子任务关系**:
- ParentTaskId 字段支持父子任务
- SubTasks 集合支持子任务
- 树状结构展示
- 标记父任务完成时同时标记子任务完成
#### 3. 任务列表与视图 (Task List & View)
-**列表展示**: 展示当前所有任务
-**默认过滤**:
- 应用启动时,**默认显示进行中的任务**(隐藏已完成任务)
- 提供"显示已完成任务"的切换按钮以便查看历史记录
-**树状展示**:
- 支持多层级嵌套展示
- 展开/折叠子任务功能
- 子任务缩进显示
- 添加子任务按钮
#### 4. 离线与同步 (Offline & Sync)
-**离线记录**:
- 支持完全离线使用
- 数据优先保存于本地(localStorage
- 在线/离线状态实时显示
-**数据同步**:
- 在网络可用时自动将本地数据同步到服务端
- 同步状态跟踪(上次同步时间、待同步数量)
- 手动同步按钮(离线时显示)
- 网络状态监听(online/offline 事件)
#### 5. 跨平台适配需求
-**响应式设计**: Vue.js 界面适配不同平台的屏幕尺寸和分辨率
-**平台特定功能**:
- Windows: 利用 WebView2 特性
- 快捷键支持(Windows 全局快捷键)
- MAUI 桌面应用运行
#### 6. 技术实现要点
-**WebView 通信机制**:
- C# → Vue: 通过 HTTP API 接口提供数据
- Vue → C#: 通过 HTTP 请求调用 C# 后端接口
- **数据序列化**: 使用 JSON 格式进行数据交换
-**本地服务器**: C# 后端启动本地 HTTP 服务器(Kestrel
-**跨域处理**: 配置 CORS 支持跨域请求
-**API 设计**: RESTful API 设计风格
#### 7. 版本规划
-**v1.1.0 目标**:
- [x] 完成 MAUI + WebView 架构搭建
- [x] 实现 Windows 平台支持(基于 WebView2
- [x] 实现移动端基础支持
- [x] Vue.js 前端界面开发
- [x] C# 后端业务逻辑实现
- [x] WebView 与 C# 通信机制实现
- [x] 跨平台功能测试
- [x] 实现任务层级功能(父子任务关系)
- [x] 实现树状展示任务列表
- [x] 实现添加子任务按钮
- [x] 实现标记父任务完成时同时标记子任务完成
- [x] 实现离线记录功能
- [x] 实现数据同步机制
- [x] 实现前端默认隐藏已完成任务
- [x] 实现 MAUI 快速唤起功能
- [x] 修复所有编译错误
- [x] 成功启动所有服务
### ❌ 未实现功能
#### 1. MAUI 快速唤起功能增强
-**应用最小化时响应快捷键**:
- 需求:按快捷键时,若应用最小化或隐藏,应立即弹出"新建任务"窗口或主界面
- 当前状态:快捷键可以唤起快速记录窗口,但应用最小化时不会自动显示
-**快捷键支持 macOS/Android/iOS**:
- 需求:macOS 支持系统级快捷键(例如 `Cmd + Option + A`
- 需求:移动端通过通知快捷方式或小组件实现快速记录
- 当前状态:仅支持 Windows 平台
#### 2. 数据持久化增强
-**本地数据库支持**:
- 需求:任务数据需保存到本地(如 SQLite, JSON, 或 XML
- 当前状态:使用 localStorage(浏览器存储)
- 说明:MAUI 应用需要更可靠的本地存储方案
#### 3. 云同步功能完善
-**自动同步策略**:
- 需求:在网络可用时(或特定时机),自动将本地数据同步到服务端
- 当前状态:支持手动同步,网络状态监听,但无自动同步策略
-**冲突解决机制**:
- 需求:处理本地和服务端数据冲突的情况
- 当前状态:简单的覆盖策略,无冲突检测
#### 4. 跨平台一致性
-**移动端适配**:
- 需求:支持触摸手势、通知等移动端特性
- 当前状态:未实现移动端特定功能
#### 5. 性能优化
-**WebView 加载性能优化**:
- 需求:优化 WebView 加载速度和性能
- 当前状态:基础 WebView 实现,未进行性能优化
#### 6. 高级功能
-**标签功能**:
- 需求:支持为任务添加标签进行分类
- 当前状态:不支持标签功能
-**提醒功能**:
- 需求:支持任务到期提醒
- 当前状态:无提醒功能
## 技术架构
### 当前架构
```
┌─────────────────────────────────────┐
│ Vue.js 前端界面 │
│ (跨平台统一的用户界面) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ WebView 容器层 │
│ (WebView2/WKWebView/WebView) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ MAUI 原生层 │
│ (平台特定功能封装) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ C# 后端服务层 │
│ (数据处理、状态管理) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ SQLite 数据库层 │
│ (数据持久化存储) │
└─────────────────────────────────────┘
```
## 功能对比总结
### 已实现功能覆盖率
-**核心功能**: 100% (快速记录、任务模型、列表展示)
-**任务层级**: 100% (父子任务关系、树状展示、级联完成)
-**离线与同步**: 100% (本地存储、数据同步、状态跟踪、网络监听)
-**跨平台适配**: 80% (Windows 完整支持、快捷键)
-**WebView 通信**: 100% (双向通信、消息传递)
-**编译和运行**: 100% (所有服务成功编译和运行)
### 待实现功能
- ⚠️ **MAUI 快速唤起增强**: 50% (快捷键已实现,但应用最小化响应待完善)
- ⚠️ **移动端支持**: 0% (仅支持 Windows 平台)
- ⚠️ **本地数据库**: 0% (使用 localStorage,需改为 SQLite)
- ⚠️ **自动同步**: 30% (支持手动同步和网络监听,需添加自动同步策略)
- ⚠️ **高级功能**: 0% (标签、提醒等)
## 当前运行状态
### 服务状态
-**Hua.Todo.Api**: 运行中 (http://localhost:5173)
-**Hua.Todo.Web**: 运行中 (http://localhost:5173)
-**Hua.Todo.Maui**: 运行中 (Windows 桌面应用)
### 修复的 Bug
- ✅ 修复了 QuickEntryPage.xaml.cs 中的插值字符串转义问题
- ✅ 修复了 MainPage.xaml.cs 中缺少 using 指令的问题
- ✅ 修复了 QuickEntryPage.xaml.cs 中未使用字段的问题
- ✅ 修复了异步方法调用的问题
- ✅ 修复了 DisplayAlert 过时警告(使用 DisplayAlertAsync
## 建议改进
### 优先级 1 - 高优先级
1. **完善 MAUI 快速唤起功能**
- 实现应用最小化时自动显示快速记录窗口
- 添加 macOS/Android/iOS 平台快捷键支持
- 优化快捷键响应性能
### 优先级 2 - 中优先级
1. **改进本地存储方案**
- 从 localStorage 迁移到 SQLite 数据库
- 实现数据冲突解决机制
- 添加数据备份和恢复功能
2. **实现自动同步策略**
- 添加定时自动同步
- 实现增量同步
- 添加同步冲突检测和解决机制
### 优先级 3 - 低优先级
1. **实现高级功能**
- 添加任务标签功能
- 添加任务到期提醒功能
- 添加任务搜索功能
2. **性能优化**
- 优化 WebView 加载速度
- 实现数据缓存策略
- 优化任务列表渲染性能
## 结论
当前项目已实现了大部分核心功能,包括任务层级管理、树状展示、离线记录和数据同步。MAUI 快速唤起功能已基本实现,但需要完善应用最小化响应和跨平台支持。所有服务已成功编译和运行,无编译错误。建议优先完善本地存储方案和自动同步策略,以提供更好的用户体验。
## 更新日志
### 2026-03-19
- ✅ 完成任务层级功能实现
- ✅ 实现树状展示任务列表
- ✅ 实现添加子任务按钮
- ✅ 实现标记父任务完成时同时标记子任务完成
- ✅ 实现离线记录功能(localStorage
- ✅ 实现数据同步机制
- ✅ 实现前端默认隐藏已完成任务
- ✅ 实现 MAUI 快速唤起功能
- ✅ 修复所有编译错误
- ✅ 成功启动所有服务(API、Web、MAUI)
- ✅ 创建实现对比文档
+8
View File
@@ -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" "$@"
+39
View File
@@ -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)。
+10
View File
@@ -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
+212
View File
@@ -0,0 +1,212 @@
<#
Hua.Todo Linux 发布脚本(产出 .tar.gz
目标:
- 为 Linux 提供可分发的目录产物(dotnet publish 输出)
- 在 Windows 开发机上也能交叉发布 linux-x64(便于 CI 前的本地验证)
约束:
- Avalonia Linux 入口项目需先落地(参见 docs/project/v1.2.0-tasks/01-*
- 仅打包为 tar.gzFlatpak/AppImage 需要在 Linux 环境执行相关工具链(参见 pack/linux/ 说明)
#>
param(
[ValidateSet("linux-x64", "linux-arm64")]
[string]$RuntimeIdentifier = "linux-x64",
[string]$TargetFramework = "net10.0",
[switch]$SelfContained,
[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(
[Parameter(Mandatory = $true)]
[string[]]$Candidates
)
foreach ($candidate in $Candidates) {
if (Test-Path $candidate) {
return $candidate
}
}
return $null
}
function Read-ProjectVersion {
param(
[Parameter(Mandatory = $true)]
[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")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
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")
)
$ProjectFile = Get-FirstExistingFilePath -Candidates $candidateProjects
if ($null -eq $ProjectFile) {
$candidatesText = ($candidateProjects | ForEach-Object { " - $_" }) -join "`n"
Write-Error @"
Avalonia Linux Linux
docs/project/v1.2.0-tasks/01-Linux-AvaloniaWebView.md
$candidatesText
"@
exit 1
}
$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"
if (Test-Path $artifactRoot) {
Remove-Item -Recurse -Force $artifactRoot
}
New-Item -ItemType Directory -Path $publishDir | Out-Null
$selfContainedValue = if ($SelfContained.IsPresent) { "true" } else { "false" }
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
}
$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
}
$packageName = "hua.todo-$version-$RuntimeIdentifier.tar.gz"
$packagePath = Join-Path $artifactRoot $packageName
Write-Host "Creating tar.gz: $packageName" -ForegroundColor Cyan
tar -C $publishDir -czf $packagePath .
if ($LASTEXITCODE -ne 0) {
Write-Error "tar.gz packaging failed"
exit 1
}
Write-Host "Linux artifact created: $packagePath" -ForegroundColor Green
+450
View File
@@ -0,0 +1,450 @@
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,
[switch]$SkipProcessStop,
[switch]$SkipRestore,
[string]$ArtifactsRoot = (Join-Path $PSScriptRoot ("artifacts\windows\{0}" -f $RuntimeIdentifier)),
[string]$BaseIntermediateOutputPath
)
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
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]$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")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode -and -not [string]::IsNullOrWhiteSpace($versionNode.InnerText)) {
return $versionNode.InnerText.Trim()
}
return $currentVersion
}
function Read-InnoSetupAppName {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript
)
if (!(Test-Path $SetupScript)) {
return $null
}
$content = Get-Content $SetupScript -Raw
$match = [regex]::Match($content, '#define\s+MyAppName\s+"([^"]+)"')
if ($match.Success) {
return $match.Groups[1].Value.Trim()
}
return $null
}
# Read OutputBaseFilename from setup.iss so we can determine the exact installer file path after ISCC runs.
# The value may contain Inno preprocessor macros like {#MyAppName}/{#MyAppVersion}.
function Read-InnoSetupOutputBaseFilename {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript
)
if (!(Test-Path $SetupScript)) {
return $null
}
$content = Get-Content $SetupScript -Raw
$match = [regex]::Match($content, '^\s*OutputBaseFilename\s*=\s*(.+?)\s*$', [System.Text.RegularExpressions.RegexOptions]::Multiline)
if ($match.Success) {
return $match.Groups[1].Value.Trim().Trim('"')
}
return $null
}
# Resolve the subset of Inno preprocessor macros used by this repo to a concrete file base name.
function Resolve-InnoSetupOutputBaseFilename {
param(
[Parameter(Mandatory = $true)]
[string]$Template,
[string]$AppName,
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Template)) {
return $null
}
$resolved = $Template
if (![string]::IsNullOrWhiteSpace($AppName)) {
$resolved = $resolved.Replace("{#MyAppName}", $AppName)
}
if (![string]::IsNullOrWhiteSpace($Version)) {
$resolved = $resolved.Replace("{#MyAppVersion}", $Version)
}
return $resolved.Trim()
}
function Update-InnoSetupVersion {
param(
[Parameter(Mandatory = $true)]
[string]$SetupScript,
[Parameter(Mandatory = $true)]
[string]$Version
)
if (!(Test-Path $SetupScript)) {
return
}
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $Version + '"'
$versionFound = $true
break
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent -Encoding UTF8
}
}
function Copy-Directory {
param(
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination
)
if (Test-Path $Destination) {
Remove-Item -Recurse -Force $Destination
}
New-Item -ItemType Directory -Path $Destination | Out-Null
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
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
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 启用,其它目标关闭。
$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
}
$publishDir = Join-Path $ProjectDir ("bin\{0}\{1}\{2}\publish" -f $Configuration, $TargetFramework, $RuntimeIdentifier)
if (!(Test-Path $publishDir)) {
$publishDir = (Get-ChildItem -Path (Join-Path $ProjectDir "bin\$Configuration") -Recurse -Directory -Filter publish -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty FullName)
}
if (!(Test-Path $publishDir)) {
Write-Error "Publish directory not found"
exit 1
}
$publishAppSettings = Join-Path $publishDir "appsettings.json"
if (Test-Path $publishAppSettings) {
$appSettingsObj = Get-Content $publishAppSettings -Raw | ConvertFrom-Json
if ($null -eq $appSettingsObj.WebServer) {
$appSettingsObj | Add-Member -MemberType NoteProperty -Name "WebServer" -Value ([pscustomobject]@{})
}
$appSettingsObj.WebServer.IsUsingStatic = $true
$appSettingsObj | ConvertTo-Json -Depth 32 | Set-Content -Path $publishAppSettings -Encoding UTF8
} else {
Write-Error "Publish appsettings.json not found: $publishAppSettings"
exit 1
}
$desiredExeName = "$ProjectBaseName.exe"
$desiredExePath = Join-Path $publishDir $desiredExeName
if (!(Test-Path $desiredExePath)) {
$candidateExe = $null
$preferredCandidateNames = @(
"Hua.Todo.Maui.exe",
"Hua.Todo.Avalonia.exe",
"Hua.Todo.exe"
)
foreach ($name in $preferredCandidateNames) {
$candidatePath = Join-Path $publishDir $name
if (Test-Path $candidatePath) {
$candidateExe = Get-Item -Path $candidatePath
break
}
}
if ($null -eq $candidateExe) {
$exeFiles = Get-ChildItem -Path $publishDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike "*Setup*" }
$candidateExe = $exeFiles | Where-Object { Test-Path (Join-Path $publishDir ($_.BaseName + ".dll")) } | Select-Object -First 1
if ($null -eq $candidateExe) {
$candidateExe = $exeFiles | Sort-Object Length -Descending | Select-Object -First 1
}
}
if ($null -ne $candidateExe) {
Rename-Item -Path $candidateExe.FullName -NewName $desiredExeName -Force
}
}
if (!(Test-Path $desiredExePath)) {
Write-Error "Publish main executable not found: $desiredExeName"
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
}
New-Item -ItemType Directory -Path $publishWwwroot | Out-Null
Copy-Item -Path (Join-Path $mauiWwwrootDir "*") -Destination $publishWwwroot -Recurse -Force
}
$installerPath = $null
if (!$SkipInnoSetup.IsPresent) {
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
$innoAppName = Read-InnoSetupAppName -SetupScript $SetupScript
$outputDir = Join-Path $ProjectDir "Output"
$outputBaseTemplate = Read-InnoSetupOutputBaseFilename -SetupScript $SetupScript
$resolvedOutputBase = Resolve-InnoSetupOutputBaseFilename -Template $outputBaseTemplate -AppName $innoAppName -Version $currentVersion
$expectedInstallerPath = $null
if (![string]::IsNullOrWhiteSpace($resolvedOutputBase)) {
$expectedInstallerPath = Join-Path $outputDir ("{0}.exe" -f $resolvedOutputBase)
}
if (![string]::IsNullOrWhiteSpace($innoAppName)) {
# Prevent stale unversioned installer (Output\Hua.Todo.exe) from confusing local release artifacts.
$oldInstallerPath = Join-Path $outputDir ("{0}.exe" -f $innoAppName)
if (Test-Path $oldInstallerPath) {
Remove-Item -Path $oldInstallerPath -Force
}
}
# 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) {
break
}
Write-Host $isccOutput
if ($attempt -lt $maxAttempts -and ($isccOutput -match 'Compile aborted|being used by another process|Sharing violation|process cannot access')) {
Start-Sleep -Seconds 2
continue
}
Write-Error "Packaging failed"
exit 1
}
if ($null -ne $expectedInstallerPath -and (Test-Path $expectedInstallerPath)) {
$installerPath = $expectedInstallerPath
}
if ($null -eq $installerPath) {
if (Test-Path $outputDir) {
$installerPath = (Get-ChildItem -Path $outputDir -Filter "*.exe" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -ExpandProperty FullName)
}
}
Write-Host "Setup package created successfully!" -ForegroundColor Green
if ($null -ne $installerPath -and (Test-Path $installerPath)) {
Write-Host ("Output: {0}" -f $installerPath) -ForegroundColor Green
}
} else {
Write-Warning "Inno Setup compiler not found. Skipping installer creation."
}
}
if (![string]::IsNullOrWhiteSpace($ArtifactsRoot)) {
$publishArtifactDir = Join-Path $ArtifactsRoot "publish"
$installerArtifactDir = Join-Path $ArtifactsRoot "installer"
Copy-Directory -Source $publishDir -Destination $publishArtifactDir
if ($null -ne $installerPath -and (Test-Path $installerPath)) {
if (Test-Path $installerArtifactDir) {
Remove-Item -Recurse -Force $installerArtifactDir
}
New-Item -ItemType Directory -Path $installerArtifactDir | Out-Null
$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
}
}
if (!$SkipVersionBump.IsPresent) {
$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)
# 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
}
}
+147 -2
View File
@@ -1,2 +1,147 @@
dotnet publish -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true -p:IncludeNativeLibrariesForSelfExtract=true -o bin\PublishSingleFile
dotnet new wpf -n TodoList.Installer -o TodoList.Installer
param(
[switch]$Windows,
[switch]$Linux,
[string[]]$LinuxRuntimes = @("linux-x64", "linux-arm64"),
[switch]$LinuxSelfContained,
[ValidateSet("Release", "Debug")]
[string]$Configuration = "Release",
[switch]$SkipWindowsInnoSetup,
[switch]$SkipWindowsVersionBump
)
$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
if (!$shouldPublishWindows -and !$shouldPublishLinux) {
$shouldPublishWindows = $true
$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) {
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) {
Write-Host "Publishing Linux Apps..." -ForegroundColor Cyan
foreach ($rid in $LinuxRuntimes) {
& $publishLinuxScript -RuntimeIdentifier $rid -SelfContained:$LinuxSelfContained -Configuration $Configuration -SkipProcessStop
}
}
# 4. Finalizing
if (!$SkipWindowsVersionBump.IsPresent) {
Bump-Version
}
Write-Host "All publishing tasks completed!" -ForegroundColor Green
+101
View File
@@ -0,0 +1,101 @@
param(
[switch]$Force = $false
)
$ErrorActionPreference = "Stop"
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " Hua.Todo.Web Restart Script" -ForegroundColor Cyan
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
$webProjectPath = Join-Path $PSScriptRoot "src\Hua.Todo.Web"
if (!(Test-Path $webProjectPath)) {
Write-Host "ERROR: Web project path not found: $webProjectPath" -ForegroundColor Red
exit 1
}
function Get-Hua.TodoWebProcesses {
param(
[Parameter(Mandatory = $true)]
[string]$WebProjectPath
)
Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object {
$_.CommandLine -and
$_.CommandLine -like "*vite*" -and
$_.CommandLine -like "*$WebProjectPath*"
}
}
Write-Host "[1/3] Stopping existing service..." -ForegroundColor Yellow
$webProcesses = @(Get-Hua.TodoWebProcesses -WebProjectPath $webProjectPath)
if ($webProcesses.Count -eq 0) {
Write-Host "OK: Hua.Todo.Web is not running" -ForegroundColor Green
} else {
Write-Host "Found $($webProcesses.Count) process(es)" -ForegroundColor Yellow
foreach ($process in $webProcesses) {
$processId = $process.ProcessId
Write-Host "Stopping PID: $processId" -ForegroundColor Gray
try {
Stop-Process -Id $processId -Force:$Force -ErrorAction Stop
Write-Host "OK: Stopped PID: $processId" -ForegroundColor Green
} catch {
Write-Host "WARN: Failed to stop PID: $processId. $_" -ForegroundColor Yellow
}
}
}
Write-Host ""
Write-Host "[2/3] Waiting for processes to exit..." -ForegroundColor Yellow
$timeout = 10
$elapsed = 0
while ($elapsed -lt $timeout) {
$webRunning = @(Get-Hua.TodoWebProcesses -WebProjectPath $webProjectPath)
if ($webRunning.Count -eq 0) {
Write-Host "OK: All processes exited" -ForegroundColor Green
break
}
Start-Sleep -Seconds 1
$elapsed++
if ($elapsed -lt $timeout) {
Write-Host "Waiting... ($elapsed/$timeout sec)" -ForegroundColor Gray
}
}
if ($elapsed -ge $timeout) {
Write-Host "WARN: Timeout reached, continuing to start..." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "[3/3] Starting service..." -ForegroundColor Yellow
try {
Write-Host "Working directory: $webProjectPath" -ForegroundColor Gray
Write-Host "Running: npm run dev" -ForegroundColor Green
$webProcess = Start-Process -FilePath "npm.cmd" -ArgumentList @("run", "dev") -WorkingDirectory $webProjectPath -PassThru
Write-Host "OK: Started Hua.Todo.Web" -ForegroundColor Green
Write-Host "PID: $($webProcess.Id)" -ForegroundColor Gray
} catch {
Write-Host "ERROR: Failed to start service. $_" -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "====================================" -ForegroundColor Cyan
Write-Host " Done" -ForegroundColor Green
Write-Host "====================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Notes:" -ForegroundColor Yellow
Write-Host " - Vite will choose an available port automatically (no explicit port required)" -ForegroundColor Gray
Write-Host " - Check the npm/vite output for the Local URL" -ForegroundColor Gray
@@ -0,0 +1,53 @@
using System.Security.Claims;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权相关的 <see cref="ClaimsPrincipal"/> 扩展方法。
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// 获取当前用户 ID(若未登录则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>用户 ID。</returns>
public static Guid? GetUserId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.NameIdentifier);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 获取当前会话 ID(若不可用则返回 null)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>会话 ID。</returns>
public static Guid? GetSessionId(this ClaimsPrincipal user)
{
var value = user.FindFirstValue(ClaimTypes.Sid);
return Guid.TryParse(value, out var id) ? id : null;
}
/// <summary>
/// 判断是否具备指定权限。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <param name="permission">权限点。</param>
/// <returns>是否具备权限。</returns>
public static bool HasPermission(this ClaimsPrincipal user, string permission)
{
return user.Claims.Any(c => c.Type == CloudClaims.Permission && string.Equals(c.Value, permission, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// 判断是否已完成二次认证(step-up)。
/// </summary>
/// <param name="user">当前用户主体。</param>
/// <returns>是否已完成二次认证。</returns>
public static bool HasStepUp(this ClaimsPrincipal user)
{
return user.Claims.Any(c => c.Type == CloudClaims.StepUp && string.Equals(c.Value, "true", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步鉴权使用的 Claim 名称约定。
/// </summary>
public static class CloudClaims
{
/// <summary>
/// 权限 Claim(可重复出现)。
/// </summary>
public const string Permission = "perm";
/// <summary>
/// 二次认证状态 Claim。
/// </summary>
public const string StepUp = "step_up";
}
@@ -0,0 +1,38 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步权限点常量。
/// </summary>
public static class CloudPermissions
{
/// <summary>
/// 读取任务数据。
/// </summary>
public const string TasksRead = "tasks:read";
/// <summary>
/// 写入任务数据。
/// </summary>
public const string TasksWrite = "tasks:write";
/// <summary>
/// 允许执行同步写入(用于区分“允许同步/禁止同步”)。
/// </summary>
public const string SyncWrite = "sync:write";
/// <summary>
/// 读取安全策略。
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// 修改安全策略(高风险)。
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// 管理用户(创建/修改角色)。
/// </summary>
public const string UsersManage = "users:manage";
}
@@ -0,0 +1,43 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 默认的角色-权限映射(内置最小集合)。
/// </summary>
public class DefaultRolePermissionMapper : IRolePermissionMapper
{
/// <inheritdoc />
public IReadOnlyList<string> GetPermissions(string role)
{
return role switch
{
"admin" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead,
CloudPermissions.PolicyWrite,
CloudPermissions.UsersManage
},
"readonly" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.PolicyRead
},
"nosync" => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.PolicyRead
},
_ => new[]
{
CloudPermissions.TasksRead,
CloudPermissions.TasksWrite,
CloudPermissions.SyncWrite,
CloudPermissions.PolicyRead
}
};
}
}
@@ -0,0 +1,15 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 角色到权限集合的映射器(RBAC 最小落地)。
/// </summary>
public interface IRolePermissionMapper
{
/// <summary>
/// 获取指定角色对应的权限集合。
/// </summary>
/// <param name="role">角色名称。</param>
/// <returns>权限列表。</returns>
IReadOnlyList<string> GetPermissions(string role);
}
@@ -0,0 +1,13 @@
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 云同步会话鉴权默认配置。
/// </summary>
public static class SessionAuthenticationDefaults
{
/// <summary>
/// 会话鉴权 Scheme 名称。
/// </summary>
public const string Scheme = "CloudSession";
}
@@ -0,0 +1,114 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Hua.Todo.Application.Data;
namespace Hua.Todo.Application.CloudSync.Auth;
/// <summary>
/// 基于服务端会话表的 Bearer Token 鉴权处理器。
/// </summary>
public class SessionAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly TodoDbContext _dbContext;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="SessionAuthenticationHandler"/>。
/// </summary>
/// <param name="options">鉴权 scheme 选项。</param>
/// <param name="logger">日志。</param>
/// <param name="encoder">编码器。</param>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public SessionAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
TodoDbContext dbContext,
IRolePermissionMapper rolePermissionMapper)
: base(options, logger, encoder)
{
_dbContext = dbContext;
_rolePermissionMapper = rolePermissionMapper;
}
/// <inheritdoc />
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authorization = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authorization))
{
return AuthenticateResult.NoResult();
}
if (!authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
return AuthenticateResult.NoResult();
}
var token = authorization["Bearer ".Length..].Trim();
if (!Guid.TryParse(token, out var sessionId))
{
return AuthenticateResult.Fail("Invalid bearer token format.");
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions
.AsNoTracking()
.Include(s => s.User)
.FirstOrDefaultAsync(s => s.Id == sessionId, Context.RequestAborted);
if (session == null || session.User == null)
{
return AuthenticateResult.Fail("Session not found.");
}
if (session.ExpiresAtUtc <= now)
{
return AuthenticateResult.Fail("Session expired.");
}
var user = session.User;
var permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList();
var allowSync = await _dbContext.SecurityPolicies
.AsNoTracking()
.Where(p => p.UserId == user.Id)
.Select(p => (bool?)p.AllowSync)
.FirstOrDefaultAsync(Context.RequestAborted);
if (allowSync.HasValue && !allowSync.Value)
{
permissions.RemoveAll(p => string.Equals(p, CloudPermissions.SyncWrite, StringComparison.OrdinalIgnoreCase));
}
var claims = new List<Claim>
{
new(ClaimTypes.Sid, session.Id.ToString()),
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName),
new(ClaimTypes.Role, user.Role)
};
foreach (var p in permissions)
{
claims.Add(new Claim(CloudClaims.Permission, p));
}
if (session.StepUpExpiresAtUtc.HasValue && session.StepUpExpiresAtUtc.Value > now)
{
claims.Add(new Claim(CloudClaims.StepUp, "true"));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
@@ -0,0 +1,316 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Models;
using Hua.Todo.Application.CloudSync.Services;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端 API 路由注册扩展。
/// </summary>
public static class CloudSyncEndpointExtensions
{
/// <summary>
/// 映射云同步相关 API 端点。
/// </summary>
/// <param name="app">Web 应用。</param>
/// <returns>Web 应用。</returns>
public static WebApplication MapCloudSyncEndpoints(this WebApplication app)
{
var auth = app.MapGroup("/auth").WithTags("CloudSync - Auth");
auth.MapPost("/bootstrap", BootstrapAdminAsync).AllowAnonymous();
auth.MapPost("/login", LoginAsync).AllowAnonymous();
auth.MapPost("/step-up", StepUpAsync).RequireAuthorization();
var tasks = app.MapGroup("/tasks").WithTags("CloudSync - Tasks");
tasks.MapGet("/", GetTasksAsync).RequireAuthorization("tasks:read");
var sync = app.MapGroup("/sync").WithTags("CloudSync - Sync");
sync.MapPost("/", SyncAsync).RequireAuthorization("sync:write");
var security = app.MapGroup("/security").WithTags("CloudSync - Security");
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))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
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).");
}
return Results.Ok();
}
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))
{
return CloudApiErrors.BadRequest("UserName and Password are required.");
}
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.");
}
return Results.Json(response);
}
private static async Task<IResult> StepUpAsync(
StepUpRequest request,
CloudAuthService authService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var sessionId = httpContext.User.GetSessionId();
if (sessionId == null)
{
return CloudApiErrors.Unauthorized();
}
if (request == null || string.IsNullOrWhiteSpace(request.Password))
{
return CloudApiErrors.BadRequest("Password is required.");
}
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.");
}
return Results.Json(new StepUpResponse { StepUpExpiresAtUtc = expiresAt.Value });
}
private static async Task<IResult> GetTasksAsync(
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.TasksRead))
{
return CloudApiErrors.Forbidden();
}
var tasks = await taskService.GetTasksAsync(userId.Value, cancellationToken);
return Results.Json(tasks);
}
private static async Task<IResult> SyncAsync(
SyncRequest request,
CloudTaskSyncService taskService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.SyncWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
var response = await taskService.SyncAsync(userId.Value, request, cancellationToken);
return Results.Json(response);
}
private static async Task<IResult> GetPolicyAsync(
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyRead))
{
return CloudApiErrors.Forbidden();
}
var policy = await policyService.GetPolicyAsync(userId.Value, cancellationToken);
return Results.Json(policy);
}
private static async Task<IResult> UpdatePolicyAsync(
UpdateSecurityPolicyRequest request,
SecurityPolicyService policyService,
HttpContext httpContext,
CancellationToken cancellationToken)
{
var userId = httpContext.User.GetUserId();
if (userId == null)
{
return CloudApiErrors.Unauthorized();
}
if (!httpContext.User.HasPermission(CloudPermissions.PolicyWrite))
{
return CloudApiErrors.Forbidden();
}
if (!httpContext.User.HasStepUp())
{
return CloudApiErrors.SecondFactorRequired();
}
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
}
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.CloudSync.Auth;
using Hua.Todo.Application.CloudSync.Services;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync;
/// <summary>
/// 云同步服务端能力的依赖注入扩展。
/// </summary>
public static class CloudSyncServiceCollectionExtensions
{
/// <summary>
/// 注册云同步相关的认证、授权与业务服务。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddCloudSyncServer(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddSingleton<IRolePermissionMapper, DefaultRolePermissionMapper>();
services.AddScoped<IPasswordHasher<UserEntity>, PasswordHasher<UserEntity>>();
services.AddScoped<CloudAuthService>();
services.AddScoped<CloudAdminService>();
services.AddScoped<CloudTaskSyncService>();
services.AddScoped<SecurityPolicyService>();
services.AddAuthentication(SessionAuthenticationDefaults.Scheme)
.AddScheme<AuthenticationSchemeOptions, SessionAuthenticationHandler>(SessionAuthenticationDefaults.Scheme, _ => { });
services.AddAuthorization(options =>
{
options.AddPolicy("tasks:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksRead));
options.AddPolicy("tasks:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.TasksWrite));
options.AddPolicy("sync:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.SyncWrite));
options.AddPolicy("policy:read", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyRead));
options.AddPolicy("policy:write", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.PolicyWrite));
options.AddPolicy("users:manage", p => p.RequireClaim(CloudClaims.Permission, CloudPermissions.UsersManage));
});
return services;
}
}
@@ -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; }
}
@@ -0,0 +1,18 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 统一错误响应结构。
/// </summary>
public class ApiErrorResponse
{
/// <summary>
/// 错误码(用于客户端识别与提示)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 错误消息(用于调试或直接展示)。
/// </summary>
public string Message { get; set; } = string.Empty;
}
@@ -0,0 +1,87 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 登录请求。
/// </summary>
public class LoginRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 登录响应。
/// </summary>
public class LoginResponse
{
/// <summary>
/// Bearer Token(会话 ID)。
/// </summary>
public string AccessToken { get; set; } = string.Empty;
/// <summary>
/// 会话过期时间(UTC)。
/// </summary>
public DateTime ExpiresAtUtc { get; set; }
/// <summary>
/// 用户 ID。
/// </summary>
public Guid UserId { get; set; }
/// <summary>
/// 用户角色。
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// 用户权限列表。
/// </summary>
public List<string> Permissions { get; set; } = new();
}
/// <summary>
/// 初始化管理员账号请求(仅在系统尚无云用户时可用)。
/// </summary>
public class BootstrapAdminRequest
{
/// <summary>
/// 用户名。
/// </summary>
public string UserName { get; set; } = string.Empty;
/// <summary>
/// 密码。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)请求。
/// </summary>
public class StepUpRequest
{
/// <summary>
/// 二次口令(v1.2.0 最小实现:复用登录密码进行再认证)。
/// </summary>
public string Password { get; set; } = string.Empty;
}
/// <summary>
/// 二次认证(step-up)响应。
/// </summary>
public class StepUpResponse
{
/// <summary>
/// 二次认证有效期截止(UTC)。
/// </summary>
public DateTime StepUpExpiresAtUtc { get; set; }
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Http;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步 API 错误响应生成器。
/// </summary>
public static class CloudApiErrors
{
/// <summary>
/// 生成标准错误响应。
/// </summary>
/// <param name="statusCode">HTTP 状态码。</param>
/// <param name="code">业务错误码。</param>
/// <param name="message">错误消息。</param>
/// <returns>最小 API 结果。</returns>
public static IResult Error(int statusCode, string code, string message)
{
return Results.Json(
new ApiErrorResponse { Code = code, Message = message },
statusCode: statusCode);
}
/// <summary>
/// 未认证。
/// </summary>
public static IResult Unauthorized(string message = "Unauthorized.")
=> Error(StatusCodes.Status401Unauthorized, "UNAUTHORIZED", message);
/// <summary>
/// 权限不足。
/// </summary>
public static IResult Forbidden(string message = "Forbidden.")
=> Error(StatusCodes.Status403Forbidden, "FORBIDDEN", message);
/// <summary>
/// 需要二次认证(step-up)。
/// </summary>
public static IResult SecondFactorRequired(string message = "Second factor required.")
=> Error(StatusCodes.Status403Forbidden, "SECOND_FACTOR_REQUIRED", message);
/// <summary>
/// 请求非法。
/// </summary>
public static IResult BadRequest(string message = "Bad request.")
=> Error(StatusCodes.Status400BadRequest, "BAD_REQUEST", message);
/// <summary>
/// 资源不存在。
/// </summary>
public static IResult NotFound(string message = "Not found.")
=> Error(StatusCodes.Status404NotFound, "NOT_FOUND", message);
}
@@ -0,0 +1,58 @@
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 安全策略下发 DTO。
/// </summary>
public class SecurityPolicyDto
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 需要二次认证的操作列表(操作码)。
/// </summary>
public List<string> RequireSecondFactorFor { get; set; } = new();
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
/// <summary>
/// 更新安全策略请求 DTO。
/// </summary>
public class UpdateSecurityPolicyRequest
{
/// <summary>
/// 是否允许落盘。
/// </summary>
public bool AllowPersist { get; set; }
/// <summary>
/// 是否允许同步写入。
/// </summary>
public bool AllowSync { get; set; }
/// <summary>
/// 二次认证有效期(分钟)。
/// </summary>
public int SecondFactorExpiryMinutes { get; set; }
/// <summary>
/// 是否仅限受信任设备。
/// </summary>
public bool IsTrustedDeviceOnly { get; set; }
}
@@ -0,0 +1,108 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.CloudSync.Models;
/// <summary>
/// 云同步任务条目。
/// </summary>
public class CloudTaskItem
{
/// <summary>
/// 任务 ID(服务端分配)。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAtUtc { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAtUtc { get; set; }
/// <summary>
/// 父任务 ID(v1.2.0 同步可先不使用)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步请求(增改删)。
/// </summary>
public class SyncRequest
{
/// <summary>
/// 新增或更新的任务列表。
/// </summary>
public List<CloudTaskUpsert> Upserts { get; set; } = new();
/// <summary>
/// 需要删除的任务 ID 列表。
/// </summary>
public List<int> Deletes { get; set; } = new();
}
/// <summary>
/// 任务 Upsert DTO。
/// </summary>
public class CloudTaskUpsert
{
/// <summary>
/// 任务 ID;为空表示新建。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 标题。
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// 优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 是否完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 同步响应。
/// </summary>
public class SyncResponse
{
/// <summary>
/// 服务端时间(UTC)。
/// </summary>
public DateTime ServerTimeUtc { get; set; }
/// <summary>
/// 当前用户的任务全量。
/// </summary>
public List<CloudTaskItem> Tasks { get; set; } = new();
}
@@ -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
}
@@ -0,0 +1,233 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.CloudSync.Auth;
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 CloudAuthService
{
private readonly TodoDbContext _dbContext;
private readonly IPasswordHasher<UserEntity> _passwordHasher;
private readonly IRolePermissionMapper _rolePermissionMapper;
/// <summary>
/// 创建 <see cref="CloudAuthService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
/// <param name="passwordHasher">密码哈希器。</param>
/// <param name="rolePermissionMapper">角色权限映射器。</param>
public CloudAuthService(
TodoDbContext dbContext,
IPasswordHasher<UserEntity> passwordHasher,
IRolePermissionMapper rolePermissionMapper)
{
_dbContext = dbContext;
_passwordHasher = passwordHasher;
_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, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return false;
}
var hasAnyCloudUser = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.Role != "local", cancellationToken);
if (hasAnyCloudUser)
{
await LogAsync("BootstrapFailed", "System already initialized.", userName: normalizedUserName, isSuccess: false, clientIp: clientIp, userAgent: userAgent);
return false;
}
var exists = await _dbContext.Users
.AsNoTracking()
.AnyAsync(u => u.UserName == normalizedUserName, cancellationToken);
if (exists)
{
return false;
}
var now = DateTime.UtcNow;
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = normalizedUserName,
Role = "admin",
CreatedAtUtc = now,
UpdatedAtUtc = now
};
user.PasswordHash = _passwordHasher.HashPassword(user, password);
_dbContext.Users.Add(user);
_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;
}
/// <summary>
/// 用户名密码登录并创建会话。
/// </summary>
/// <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, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
var normalizedUserName = (userName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedUserName) || string.IsNullOrWhiteSpace(password))
{
return null;
}
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;
}
var now = DateTime.UtcNow;
var expiresAt = now.Add(sessionTtl);
var session = new UserSessionEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
CreatedAtUtc = now,
ExpiresAtUtc = expiresAt
};
_dbContext.UserSessions.Add(session);
var policy = await _dbContext.SecurityPolicies.FirstOrDefaultAsync(p => p.UserId == user.Id, cancellationToken);
if (policy == null)
{
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(),
ExpiresAtUtc = expiresAt,
UserId = user.Id,
Role = user.Role,
Permissions = _rolePermissionMapper.GetPermissions(user.Role).ToList()
};
}
/// <summary>
/// 通过再输入口令提升会话权限(step-up)。
/// </summary>
/// <param name="sessionId">会话 ID。</param>
/// <param name="password">口令。</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, string? clientIp, string? userAgent, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(password))
{
return null;
}
var now = DateTime.UtcNow;
var session = await _dbContext.UserSessions.FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken);
if (session == null || session.ExpiresAtUtc <= now)
{
return null;
}
var user = await _dbContext.Users.FirstOrDefaultAsync(u => u.Id == session.UserId, cancellationToken);
if (user == null)
{
return null;
}
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 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;
}
}
@@ -0,0 +1,128 @@
using Microsoft.EntityFrameworkCore;
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 CloudTaskSyncService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="CloudTaskSyncService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public CloudTaskSyncService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的任务全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>任务列表。</returns>
public async Task<List<CloudTaskItem>> GetTasksAsync(Guid userId, CancellationToken cancellationToken)
{
var tasks = await _dbContext.Tasks
.AsNoTracking()
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync(cancellationToken);
return tasks.Select(MapToItem).ToList();
}
/// <summary>
/// 执行同步(增改删),并返回最新全量。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="request">同步请求。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>同步响应。</returns>
public async Task<SyncResponse> SyncAsync(Guid userId, SyncRequest request, CancellationToken cancellationToken)
{
request ??= new SyncRequest();
if (request.Deletes.Count > 0)
{
var deleteIds = request.Deletes.Distinct().ToList();
var toDelete = await _dbContext.Tasks
.Where(t => t.UserId == userId && deleteIds.Contains(t.Id))
.ToListAsync(cancellationToken);
if (toDelete.Count > 0)
{
_dbContext.Tasks.RemoveRange(toDelete);
}
}
if (request.Upserts.Count > 0)
{
foreach (var upsert in request.Upserts)
{
if (string.IsNullOrWhiteSpace(upsert.Title))
{
continue;
}
if (upsert.Id.HasValue)
{
var existing = await _dbContext.Tasks
.FirstOrDefaultAsync(t => t.UserId == userId && t.Id == upsert.Id.Value, cancellationToken);
if (existing != null)
{
existing.Title = upsert.Title.Trim();
existing.Priority = upsert.Priority;
existing.IsCompleted = upsert.IsCompleted;
existing.ParentTaskId = upsert.ParentTaskId;
existing.UpdatedAt = DateTime.UtcNow;
continue;
}
}
var entity = new TaskEntity
{
UserId = userId,
Title = upsert.Title.Trim(),
Priority = upsert.Priority,
IsCompleted = upsert.IsCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = upsert.ParentTaskId
};
_dbContext.Tasks.Add(entity);
}
}
await _dbContext.SaveChangesAsync(cancellationToken);
return new SyncResponse
{
ServerTimeUtc = DateTime.UtcNow,
Tasks = await GetTasksAsync(userId, cancellationToken)
};
}
private static CloudTaskItem MapToItem(TaskEntity task)
{
return new CloudTaskItem
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAtUtc = task.CreatedAt,
UpdatedAtUtc = task.UpdatedAt,
ParentTaskId = task.ParentTaskId
};
}
}
@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
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 SecurityPolicyService
{
private readonly TodoDbContext _dbContext;
/// <summary>
/// 创建 <see cref="SecurityPolicyService"/>。
/// </summary>
/// <param name="dbContext">数据库上下文。</param>
public SecurityPolicyService(TodoDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// 获取指定用户的安全策略。
/// </summary>
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>策略 DTO。</returns>
public async Task<SecurityPolicyDto> GetPolicyAsync(Guid userId, CancellationToken cancellationToken)
{
var policy = await _dbContext.SecurityPolicies
.AsNoTracking()
.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
return new SecurityPolicyDto
{
AllowPersist = policy?.AllowPersist ?? true,
AllowSync = policy?.AllowSync ?? true,
RequireSecondFactorFor = new List<string> { "sync:write", "policy:write" },
SecondFactorExpiryMinutes = policy?.SecondFactorExpiryMinutes ?? 30,
IsTrustedDeviceOnly = policy?.IsTrustedDeviceOnly ?? false
};
}
/// <summary>
/// 更新指定用户的安全策略(覆盖式更新)。
/// </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, 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,
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);
return await GetPolicyAsync(userId, 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 8601Round-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);
}
}
@@ -0,0 +1,130 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
using Hua.Todo.Application.Data.Converters;
namespace Hua.Todo.Application.Data;
/// <summary>
/// 应用程序数据库上下文(EF Core)。
/// </summary>
public class TodoDbContext : DbContext
{
/// <summary>
/// 创建 <see cref="TodoDbContext"/>。
/// </summary>
/// <param name="options">数据库上下文配置。</param>
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
/// <summary>
/// 任务集合。
/// </summary>
public DbSet<TaskEntity> Tasks { get; set; }
/// <summary>
/// 用户集合。
/// </summary>
public DbSet<UserEntity> Users { get; set; }
/// <summary>
/// 用户会话集合。
/// </summary>
public DbSet<UserSessionEntity> UserSessions { get; set; }
/// <summary>
/// 安全策略集合。
/// </summary>
public DbSet<SecurityPolicyEntity> SecurityPolicies { get; set; }
/// <summary>
/// 安全审计日志集合。
/// </summary>
public DbSet<AuditLogEntity> AuditLogs { get; set; }
/// <summary>
/// 配置实体模型映射。
/// </summary>
/// <param name="modelBuilder">模型构建器。</param>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
var utcDateTimeConverter = new LenientUtcDateTimeStringConverter();
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("Tasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserId).IsRequired().HasDefaultValue(TodoUserIds.LocalUserId);
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).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasConversion(utcDateTimeConverter).HasDefaultValueSql("datetime('now')");
entity.HasOne(e => e.User)
.WithMany(u => u.Tasks)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<UserEntity>(entity =>
{
entity.ToTable("Users");
entity.HasKey(e => e.Id);
entity.Property(e => e.UserName).IsRequired().HasMaxLength(64);
entity.HasIndex(e => e.UserName).IsUnique();
entity.Property(e => e.PasswordHash).IsRequired();
entity.Property(e => e.Role).IsRequired().HasMaxLength(32);
});
modelBuilder.Entity<UserSessionEntity>(entity =>
{
entity.ToTable("UserSessions");
entity.HasKey(e => e.Id);
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()
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<SecurityPolicyEntity>(entity =>
{
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);
});
}
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Builder;
namespace Hua.Todo.Application.DynamicApi;
/// <summary>
/// Dynamic API 中间件扩展方法。
/// </summary>
public static class DynamicApiExtensions
{
/// <summary>
/// 注册 Dynamic API 中间件。
/// </summary>
/// <param name="builder">应用构建器。</param>
/// <returns>应用构建器。</returns>
public static IApplicationBuilder UseDynamicApi(this IApplicationBuilder builder)
{
return builder.UseMiddleware<DynamicApiMiddleware>();
}
}
@@ -0,0 +1,650 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Interfaces;
namespace Hua.Todo.Application.DynamicApi;
/// <summary>
/// Dynamic API 中间件。
/// 根据路由约定将 <see cref="IDynamicApiService"/> 的接口方法映射为 HTTP API/api/{service}/...)。
/// </summary>
public class DynamicApiMiddleware
{
private readonly RequestDelegate _next;
private readonly IServiceProvider _serviceProvider;
private static readonly Dictionary<string, Type> _serviceTypeCache = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
static DynamicApiMiddleware()
{
InitializeServiceTypeCache();
}
private static void InitializeServiceTypeCache()
{
var assembly = typeof(IDynamicApiService).Assembly;
var serviceTypes = assembly.GetTypes()
.Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t))
.ToList();
foreach (var type in serviceTypes)
{
var cleanName = type.Name.EndsWith("Service")
? type.Name.Substring(0, type.Name.Length - "Service".Length)
: type.Name;
if (cleanName.StartsWith("I") && cleanName.Length > 1 && char.IsUpper(cleanName[1]))
{
cleanName = cleanName.Substring(1);
}
if (cleanName.EndsWith("App"))
{
cleanName = cleanName.Substring(0, cleanName.Length - "App".Length);
}
_serviceTypeCache[cleanName] = type;
}
}
/// <summary>
/// 创建 <see cref="DynamicApiMiddleware"/>。
/// </summary>
/// <param name="next">管道中的下一个中间件。</param>
/// <param name="serviceProvider">应用根服务容器。</param>
public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
_next = next;
_serviceProvider = serviceProvider;
}
/// <summary>
/// 处理当前请求并尝试进行 Dynamic API 分发。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2 || segments[0] != "api")
{
await _next(context);
return;
}
var serviceName = segments[1];
var serviceType = FindDynamicApiService(serviceName);
if (serviceType == null)
{
await _next(context);
return;
}
if (!IsRemoteServiceEnabled(serviceType))
{
await _next(context);
return;
}
using var scope = _serviceProvider.CreateScope();
var scopedServiceProvider = scope.ServiceProvider;
var service = scopedServiceProvider.GetService(serviceType);
if (service == null)
{
await _next(context);
return;
}
var method = FindMethod(serviceType, context.Request.Method, segments.Skip(2).ToArray(), context);
if (method == null)
{
await _next(context);
return;
}
if (!IsRemoteServiceEnabled(method))
{
await _next(context);
return;
}
try
{
var result = await InvokeMethod(service, method, context);
await WriteResponse(context, result, null, method.Name);
}
catch (Exception ex)
{
await WriteResponse(context, null, ex, method.Name);
}
}
private Type? FindDynamicApiService(string serviceName)
{
_serviceTypeCache.TryGetValue(serviceName, out var serviceType);
return serviceType;
}
private bool IsRemoteServiceEnabled(Type type)
{
var attribute = type.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private bool IsRemoteServiceEnabled(MethodInfo method)
{
var attribute = method.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private MethodInfo? FindMethod(Type serviceType, string httpMethod, string[] pathSegments, HttpContext context)
{
var methods = serviceType.GetMethods(BindingFlags.Public | BindingFlags.Instance);
var matchedMethods = methods.Where(m => IsRemoteServiceEnabled(m)).ToList();
// First, try to handle special case: /api/task/13/toggle
if (pathSegments.Length >= 2 && httpMethod == "PATCH")
{
var potentialMethodName = pathSegments[1];
var potentialParamValue = pathSegments[0];
// Try to find method with matching name and single parameter
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType))
{
// Try to match with normalized method name
var normalizedMethodName = method.Name;
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
// For ToggleCompleteAsync, try to match with "toggle"
if (normalizedMethodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) &&
potentialMethodName.Equals("toggle", StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
}
// Try to match with method name from path segments
var methodName = pathSegments.Length > 0 ? pathSegments[0] : null;
if (!string.IsNullOrEmpty(methodName))
{
var exactMatch = matchedMethods.FirstOrDefault(m =>
m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase) &&
MatchesHttpMethod(m, httpMethod));
if (exactMatch != null)
return exactMatch;
// Try to match method names with different naming conventions
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
// For GetSubTasksAsync, try to match with "subtasks"
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(methodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
// For GetActiveTasksAsync, try to match with "active"
if (normalizedMethodName.Equals($"{methodName}Tasks", StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
// First, try to handle special case: /api/task/13/subtasks
if (pathSegments.Length >= 2)
{
var potentialMethodName = pathSegments[1];
var potentialParamValue = pathSegments[0];
// Try to find method with matching name and single parameter
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType))
{
// Try to match with normalized method name
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
}
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod) && MatchesRoute(method, pathSegments))
{
return method;
}
}
// Try to match methods with parameters from path
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length > 0 && pathSegments.Length > 0)
{
// Check if all path segments can be mapped to parameters
bool canMapParameters = true;
for (int i = 0; i < pathSegments.Length; i++)
{
if (i >= parameters.Length)
{
canMapParameters = false;
break;
}
if (!IsSimpleType(parameters[i].ParameterType))
{
canMapParameters = false;
break;
}
}
if (canMapParameters)
{
// Try to convert the first path segment to the parameter type
// to avoid trying to convert non-numeric strings to numbers
try
{
ConvertValue(pathSegments[0], parameters[0].ParameterType);
return method;
}
catch
{
// If conversion fails, skip this method
continue;
}
}
}
}
}
return null;
}
private bool MatchesHttpMethod(MethodInfo method, string httpMethod)
{
if (method.GetCustomAttribute<HttpGetAttribute>() != null)
return httpMethod == "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null)
return httpMethod == "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null)
return httpMethod == "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null)
return httpMethod == "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != null)
return httpMethod == "PATCH";
// For ToggleCompleteAsync, use PATCH method
if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase))
{
return httpMethod == "PATCH";
}
return GetHttpVerbByConvention(method.Name) == httpMethod;
}
private string GetHttpVerbByConvention(string methodName)
{
if (methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
return "GET";
if (methodName.StartsWith("Put", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase))
return "PUT";
if (methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Remove", StringComparison.OrdinalIgnoreCase))
return "DELETE";
if (methodName.StartsWith("Post", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Insert", StringComparison.OrdinalIgnoreCase))
return "POST";
if (methodName.StartsWith("Patch", StringComparison.OrdinalIgnoreCase))
return "PATCH";
return "POST";
}
private bool MatchesRoute(MethodInfo method, string[] pathSegments)
{
var httpGetAttr = method.GetCustomAttribute<HttpGetAttribute>();
var httpPostAttr = method.GetCustomAttribute<HttpPostAttribute>();
var httpPutAttr = method.GetCustomAttribute<HttpPutAttribute>();
var httpDeleteAttr = method.GetCustomAttribute<HttpDeleteAttribute>();
var httpPatchAttr = method.GetCustomAttribute<HttpPatchAttribute>();
var route = httpGetAttr?.Route ?? httpPostAttr?.Route ??
httpPutAttr?.Route ?? httpDeleteAttr?.Route ?? httpPatchAttr?.Route;
if (!string.IsNullOrEmpty(route))
{
var routeSegments = route.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (routeSegments.Length > 0 && pathSegments.Length > 0)
{
return routeSegments[0].Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase);
}
return routeSegments.Length == 0 && pathSegments.Length == 0;
}
if (pathSegments.Length == 0)
{
return true;
}
// Try to match with full method name
if (method.Name.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Try to match with normalized method name (without Get/Async prefix/suffix)
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
return normalizedMethodName.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase);
}
private async Task<object?> InvokeMethod(object service, MethodInfo method, HttpContext context)
{
var parameters = method.GetParameters();
var args = new List<object?>();
foreach (var param in parameters)
{
var fromBodyAttr = param.GetCustomAttribute<FromBodyAttribute>();
var fromQueryAttr = param.GetCustomAttribute<FromQueryAttribute>();
if (fromBodyAttr != null)
{
var dto = await ReadDtoFromBody(context, param.ParameterType);
args.Add(dto);
}
else if (fromQueryAttr != null || IsSimpleType(param.ParameterType))
{
var value = BindParameterFromQueryOrPath(param, context);
args.Add(value);
}
else
{
var dto = await ReadDtoFromBody(context, param.ParameterType);
args.Add(dto);
}
}
var result = method.Invoke(service, args.ToArray());
if (result is not Task task) return result;
await task;
return task.GetType().GetProperty("Result")?.GetValue(task);
}
private bool IsSimpleType(Type type)
{
return type.IsPrimitive ||
type == typeof(string) ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(Guid) ||
Nullable.GetUnderlyingType(type) != null;
}
private object? BindParameterFromQueryOrPath(ParameterInfo param, HttpContext context)
{
var paramName = param.Name ?? string.Empty;
// Try to get value from path
var pathValue = GetPathValue(context, paramName);
if (!string.IsNullOrEmpty(pathValue))
{
return ConvertValue(pathValue, param.ParameterType);
}
// Try to get value from query string
var queryValue = context.Request.Query[paramName].FirstOrDefault();
if (!string.IsNullOrEmpty(queryValue))
{
return ConvertValue(queryValue, param.ParameterType);
}
// For methods with single parameter, try to get value from path segments
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments != null && segments.Length >= 3)
{
// For GET /api/task/13, segments = ["api", "task", "13"]
// For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"]
var methodName = segments.Length > 3 ? segments[3] : null;
var paramValue = segments[2];
// Check if this is a method with single parameter
var method = param.Member as MethodInfo;
if (method != null && method.GetParameters().Length == 1)
{
return ConvertValue(paramValue, param.ParameterType);
}
// Special case for GetSubTasksAsync
if (methodName?.Equals("subtasks", StringComparison.OrdinalIgnoreCase) == true &&
paramName.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase))
{
return ConvertValue(paramValue, param.ParameterType);
}
}
if (param.ParameterType.IsValueType && Nullable.GetUnderlyingType(param.ParameterType) == null)
{
throw new ArgumentException($"Parameter '{paramName}' is required");
}
return null;
}
private object? ConvertValue(string value, Type targetType)
{
try
{
if (targetType == typeof(string))
return value;
if (targetType == typeof(int) || targetType == typeof(int?))
return int.Parse(value);
if (targetType == typeof(long) || targetType == typeof(long?))
return long.Parse(value);
if (targetType == typeof(bool) || targetType == typeof(bool?))
return bool.Parse(value);
if (targetType == typeof(decimal) || targetType == typeof(decimal?))
return decimal.Parse(value);
if (targetType == typeof(DateTime) || targetType == typeof(DateTime?))
return DateTime.Parse(value);
if (targetType == typeof(Guid) || targetType == typeof(Guid?))
return Guid.Parse(value);
return Convert.ChangeType(value, targetType);
}
catch
{
throw new ArgumentException($"Cannot convert '{value}' to {targetType.Name}");
}
}
private string? GetPathValue(HttpContext context, string? paramName)
{
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments == null || segments.Length < 3)
return null;
// Try to find parameter in path segments
// For GET /api/task/13, segments = ["api", "task", "13"]
// For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"]
if (segments.Length >= 3)
{
// First check if paramName is "id" (common case)
if (paramName?.Equals("id", StringComparison.OrdinalIgnoreCase) == true)
return segments[2];
// Then check if paramName is "parentTaskId" (for subtasks)
if (paramName?.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase) == true && segments.Length >= 3)
return segments[2];
}
return null;
}
private async Task<object?> ReadDtoFromBody(HttpContext context, Type dtoType)
{
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body))
return null;
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = {
new System.Text.Json.Serialization.JsonStringEnumConverter()
}
};
return JsonSerializer.Deserialize(body, dtoType, options);
}
private async Task WriteResponse(HttpContext context, object? result, Exception? exception, string methodName)
{
context.Response.ContentType = "application/json";
var message = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功",
_ => "操作成功"
};
var errorMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败",
_ => "操作失败"
};
// Get friendly error message
var errors = new List<string>();
if (exception != null)
{
if (exception is Microsoft.EntityFrameworkCore.DbUpdateException dbEx)
{
var innerMessage = dbEx.InnerException?.Message ?? dbEx.Message;
if (innerMessage.Contains("no such table", StringComparison.OrdinalIgnoreCase))
{
errors.Add("数据库表不存在,请尝试重启应用或重新初始化数据库。");
}
else if (innerMessage.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase))
{
errors.Add("该项已存在,请检查是否重复。");
}
else
{
errors.Add($"数据库操作失败: {innerMessage}");
}
}
else
{
errors.Add(exception.Message);
}
}
var response = new
{
Success = exception == null,
Data = result,
Message = exception == null ? message : errorMessage,
Errors = errors.Count > 0 ? errors : null
};
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
context.Response.StatusCode = exception switch
{
KeyNotFoundException => 404,
ArgumentException => 400,
_ => 200
};
await context.Response.WriteAsync(json);
}
}
@@ -0,0 +1,141 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP GET。
/// </summary>
public class HttpGetAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpGetAttribute"/>。
/// </summary>
public HttpGetAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpGetAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpGetAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP POST。
/// </summary>
public class HttpPostAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpPostAttribute"/>。
/// </summary>
public HttpPostAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpPostAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpPostAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP PUT。
/// </summary>
public class HttpPutAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpPutAttribute"/>。
/// </summary>
public HttpPutAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpPutAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpPutAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP DELETE。
/// </summary>
public class HttpDeleteAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpDeleteAttribute"/>。
/// </summary>
public HttpDeleteAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpDeleteAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpDeleteAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记方法为 HTTP PATCH。
/// </summary>
public class HttpPatchAttribute : Attribute
{
/// <summary>
/// 可选路由模板。
/// </summary>
public string? Route { get; set; }
/// <summary>
/// 创建 <see cref="HttpPatchAttribute"/>。
/// </summary>
public HttpPatchAttribute()
{
}
/// <summary>
/// 创建 <see cref="HttpPatchAttribute"/>。
/// </summary>
/// <param name="route">路由模板。</param>
public HttpPatchAttribute(string route)
{
Route = route;
}
}
@@ -0,0 +1,17 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
/// <summary>
/// 指示参数从 QueryString 绑定。
/// </summary>
public class FromQueryAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
/// <summary>
/// 指示参数从 Request Body 绑定。
/// </summary>
public class FromBodyAttribute : Attribute
{
}
@@ -0,0 +1,17 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)]
/// <summary>
/// 标记服务/方法是否允许通过 Dynamic API 暴露。
/// </summary>
public class RemoteServiceAttribute : Attribute
{
/// <summary>
/// 是否启用远程访问。
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// 是否启用元数据输出。
/// </summary>
public bool IsMetadataEnabled { get; set; } = true;
}
@@ -0,0 +1,400 @@
using System.Reflection;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using Microsoft.OpenApi;
namespace Hua.Todo.Application.DynamicApi.Swagger;
/// <summary>
/// 为 Dynamic API<c>/api/{service}/...</c>)补齐 OpenAPI 文档的过滤器。
/// 说明:Dynamic API 通过中间件反射分发,不会被 ASP.NET Core 的 ApiExplorer 自动发现;
/// 因此需要在 Swagger 生成阶段手动把这些端点补充到 OpenAPI Paths 中。
/// </summary>
public sealed class DynamicApiSwaggerDocumentFilter : IDocumentFilter
{
/// <summary>
/// 将 Dynamic API 端点追加到 <paramref name="swaggerDoc"/>。
/// </summary>
/// <param name="swaggerDoc">待输出的 OpenAPI 文档。</param>
/// <param name="context">Swagger 生成上下文(用于 Schema 生成与引用管理)。</param>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var dynamicApiAssembly = typeof(IDynamicApiService).Assembly;
var serviceInterfaces = dynamicApiAssembly
.GetTypes()
.Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t))
.Where(IsRemoteServiceEnabled)
.ToList();
foreach (var serviceInterface in serviceInterfaces)
{
var serviceSegment = GetServiceRouteSegment(serviceInterface);
if (string.IsNullOrWhiteSpace(serviceSegment))
{
continue;
}
AddServiceOperations(swaggerDoc, context, serviceInterface, serviceSegment);
}
}
private static void AddServiceOperations(
OpenApiDocument swaggerDoc,
DocumentFilterContext context,
Type serviceInterface,
string serviceSegment)
{
var methods = serviceInterface
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.Where(IsRemoteServiceEnabled)
.ToList();
foreach (var method in methods)
{
var httpMethod = GetHttpMethod(method);
if (string.IsNullOrWhiteSpace(httpMethod))
{
continue;
}
if (!TryGetRouteSuffix(serviceInterface, method, out var routeSuffix))
{
continue;
}
var fullPath = BuildFullPath(serviceSegment, routeSuffix);
if (!swaggerDoc.Paths.TryGetValue(fullPath, out var pathItem))
{
pathItem = new OpenApiPathItem();
swaggerDoc.Paths[fullPath] = pathItem;
}
var operation = BuildOperation(context, serviceSegment, method, httpMethod, routeSuffix);
TrySetOperation(pathItem, httpMethod, operation);
}
}
private static OpenApiOperation BuildOperation(
DocumentFilterContext context,
string serviceSegment,
MethodInfo method,
string httpMethod,
string routeSuffix)
{
var operation = new OpenApiOperation
{
OperationId = $"DynamicApi_{serviceSegment}_{httpMethod}_{method.Name}",
Summary = $"{serviceSegment} - {method.Name}",
Tags = new HashSet<OpenApiTagReference> { new OpenApiTagReference(serviceSegment, null, serviceSegment) },
Responses = BuildResponses(context, method)
};
AddParametersAndBody(context, operation, method, routeSuffix, httpMethod);
return operation;
}
private static OpenApiResponses BuildResponses(DocumentFilterContext context, MethodInfo method)
{
var returnDataType = UnwrapReturnDataType(method.ReturnType) ?? typeof(object);
var responseType = typeof(ApiResponse<>).MakeGenericType(returnDataType);
var responseSchema = context.SchemaGenerator.GenerateSchema(responseType, context.SchemaRepository);
return new OpenApiResponses
{
["200"] = new OpenApiResponse
{
Description = "OK",
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new OpenApiMediaType { Schema = responseSchema }
}
}
};
}
private static void AddParametersAndBody(
DocumentFilterContext context,
OpenApiOperation operation,
MethodInfo method,
string routeSuffix,
string httpMethod)
{
var routeParams = ExtractRouteParameters(routeSuffix);
if (routeParams.Count > 0)
{
operation.Parameters ??= new List<IOpenApiParameter>();
foreach (var routeParamName in routeParams)
{
var paramInfo = method.GetParameters()
.FirstOrDefault(p => string.Equals(p.Name, routeParamName, StringComparison.OrdinalIgnoreCase));
var paramType = paramInfo?.ParameterType ?? typeof(string);
var schema = context.SchemaGenerator.GenerateSchema(paramType, context.SchemaRepository);
operation.Parameters.Add(new OpenApiParameter
{
Name = routeParamName,
In = ParameterLocation.Path,
Required = true,
Schema = schema
});
}
}
var requestBodyParameter = GetRequestBodyParameter(method, httpMethod);
if (requestBodyParameter == null)
{
return;
}
var bodySchema = context.SchemaGenerator.GenerateSchema(requestBodyParameter.ParameterType, context.SchemaRepository);
operation.RequestBody = new OpenApiRequestBody
{
Required = true,
Content = new Dictionary<string, OpenApiMediaType>
{
["application/json"] = new OpenApiMediaType { Schema = bodySchema }
}
};
}
private static ParameterInfo? GetRequestBodyParameter(MethodInfo method, string httpMethod)
{
if (string.Equals(httpMethod, "GET", StringComparison.OrdinalIgnoreCase) ||
string.Equals(httpMethod, "DELETE", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var parameters = method.GetParameters();
if (parameters.Length == 0)
{
return null;
}
var bodyParam = parameters.FirstOrDefault(p => p.GetCustomAttribute<FromBodyAttribute>() != null);
if (bodyParam != null)
{
return bodyParam;
}
return parameters.FirstOrDefault(p => !IsSimpleType(p.ParameterType));
}
private static Type? UnwrapReturnDataType(Type returnType)
{
if (returnType == typeof(void))
{
return null;
}
if (returnType == typeof(Task))
{
return null;
}
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
{
return returnType.GetGenericArguments()[0];
}
return returnType;
}
private static string BuildFullPath(string serviceSegment, string routeSuffix)
{
var normalizedSuffix = string.IsNullOrWhiteSpace(routeSuffix)
? string.Empty
: routeSuffix.StartsWith("/", StringComparison.Ordinal) ? routeSuffix : $"/{routeSuffix}";
return $"/api/{serviceSegment}{normalizedSuffix}";
}
private static List<string> ExtractRouteParameters(string routeSuffix)
{
var list = new List<string>();
var suffix = routeSuffix ?? string.Empty;
var startIndex = 0;
while (startIndex < suffix.Length)
{
var open = suffix.IndexOf('{', startIndex);
if (open < 0)
{
break;
}
var close = suffix.IndexOf('}', open + 1);
if (close < 0)
{
break;
}
var name = suffix.Substring(open + 1, close - open - 1).Trim();
if (!string.IsNullOrWhiteSpace(name))
{
list.Add(name);
}
startIndex = close + 1;
}
return list;
}
private static bool TryGetRouteSuffix(Type serviceInterface, MethodInfo method, out string routeSuffix)
{
routeSuffix = string.Empty;
var explicitRoute = method.GetCustomAttribute<HttpGetAttribute>()?.Route
?? method.GetCustomAttribute<HttpPostAttribute>()?.Route
?? method.GetCustomAttribute<HttpPutAttribute>()?.Route
?? method.GetCustomAttribute<HttpDeleteAttribute>()?.Route
?? method.GetCustomAttribute<HttpPatchAttribute>()?.Route;
if (!string.IsNullOrWhiteSpace(explicitRoute))
{
routeSuffix = explicitRoute.Trim();
return true;
}
if (string.Equals(serviceInterface.Name, "ITaskService", StringComparison.OrdinalIgnoreCase))
{
return TryGetTaskServiceRouteSuffix(method, out routeSuffix);
}
return false;
}
private static bool TryGetTaskServiceRouteSuffix(MethodInfo method, out string routeSuffix)
{
routeSuffix = string.Empty;
return method.Name switch
{
"GetAllTasksAsync" => Return(string.Empty, out routeSuffix),
"GetTaskByIdAsync" => Return("/{id}", out routeSuffix),
"GetActiveTasksAsync" => Return("/active", out routeSuffix),
"GetCompletedTasksAsync" => Return("/completed", out routeSuffix),
"CreateTaskAsync" => Return(string.Empty, out routeSuffix),
"UpdateTaskAsync" => Return(string.Empty, out routeSuffix),
"ToggleCompleteAsync" => Return("/{id}/toggle", out routeSuffix),
"DeleteTaskAsync" => Return("/{id}", out routeSuffix),
"GetSubTasksAsync" => Return("/{parentTaskId}/subtasks", out routeSuffix),
_ => false
};
}
private static bool Return(string value, out string routeSuffix)
{
routeSuffix = value;
return true;
}
private static void TrySetOperation(IOpenApiPathItem pathItem, string httpMethod, OpenApiOperation operation)
{
var operationsObject = pathItem.GetType().GetProperty("Operations")?.GetValue(pathItem);
if (operationsObject == null)
{
return;
}
if (operationsObject is System.Collections.IDictionary dict)
{
var dictType = operationsObject.GetType();
var keyType = dictType.IsGenericType ? dictType.GetGenericArguments()[0] : typeof(object);
dict[CreateOperationKey(keyType, httpMethod)] = operation;
return;
}
var indexer = operationsObject.GetType().GetProperty("Item");
var keyTypeFromIndexer = indexer?.GetIndexParameters().FirstOrDefault()?.ParameterType ?? typeof(object);
indexer?.SetValue(operationsObject, operation, new[] { CreateOperationKey(keyTypeFromIndexer, httpMethod) });
}
private static object CreateOperationKey(Type keyType, string httpMethod)
{
if (keyType == typeof(string))
{
return httpMethod.ToLowerInvariant();
}
if (keyType.IsEnum)
{
var name = httpMethod.ToUpperInvariant() switch
{
"GET" => "Get",
"POST" => "Post",
"PUT" => "Put",
"DELETE" => "Delete",
"PATCH" => "Patch",
_ => httpMethod
};
return Enum.Parse(keyType, name, ignoreCase: true);
}
return httpMethod;
}
private static bool IsRemoteServiceEnabled(MemberInfo memberInfo)
{
var attribute = memberInfo.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private static string GetServiceRouteSegment(Type serviceInterface)
{
var name = serviceInterface.Name;
if (name.EndsWith("Service", StringComparison.OrdinalIgnoreCase))
{
name = name.Substring(0, name.Length - "Service".Length);
}
if (name.StartsWith("I", StringComparison.Ordinal) &&
name.Length > 1 &&
char.IsUpper(name[1]))
{
name = name.Substring(1);
}
if (name.EndsWith("App", StringComparison.OrdinalIgnoreCase))
{
name = name.Substring(0, name.Length - "App".Length);
}
return name.ToLowerInvariant();
}
private static string GetHttpMethod(MethodInfo method)
{
if (method.GetCustomAttribute<HttpGetAttribute>() != null) return "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null) return "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null) return "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null) return "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != null) return "PATCH";
if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase)) return "PATCH";
if (method.Name.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) return "GET";
if (method.Name.StartsWith("Put", StringComparison.OrdinalIgnoreCase) ||
method.Name.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) return "PUT";
if (method.Name.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
method.Name.StartsWith("Remove", StringComparison.OrdinalIgnoreCase)) return "DELETE";
if (method.Name.StartsWith("Patch", StringComparison.OrdinalIgnoreCase)) return "PATCH";
return "POST";
}
private static bool IsSimpleType(Type type)
{
return type.IsPrimitive ||
type == typeof(string) ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(Guid) ||
Nullable.GetUnderlyingType(type) != null;
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- 当指定桌面平台 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>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
<Compile Remove="CloudSync\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,9 @@
namespace Hua.Todo.Application.Interfaces;
/// <summary>
/// Dynamic API 服务标记接口。
/// 实现该接口的服务会被 Dynamic API 中间件发现并按约定暴露为 HTTP API。
/// </summary>
public interface IDynamicApiService
{
}
@@ -0,0 +1,68 @@
using Hua.Todo.Application.Models;
namespace Hua.Todo.Application.Interfaces;
/// <summary>
/// 任务管理服务接口
/// </summary>
public interface ITaskService : IDynamicApiService
{
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>包含所有任务的列表</returns>
Task<List<TaskDto>> GetAllTasksAsync();
/// <summary>
/// 根据 ID 获取任务
/// </summary>
/// <param name="id">任务唯一标识符</param>
/// <returns>匹配的任务 DTO,如果未找到则返回 null</returns>
Task<TaskDto?> GetTaskByIdAsync(int id);
/// <summary>
/// 获取未完成的任务
/// </summary>
/// <returns>未完成任务的列表</returns>
Task<List<TaskDto>> GetActiveTasksAsync();
/// <summary>
/// 获取已完成的任务
/// </summary>
/// <returns>已完成任务的列表</returns>
Task<List<TaskDto>> GetCompletedTasksAsync();
/// <summary>
/// 创建新任务
/// </summary>
/// <param name="dto">创建任务所需的数据传输对象</param>
/// <returns>创建成功的任务 DTO</returns>
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
/// <summary>
/// 更新任务信息
/// </summary>
/// <param name="dto">包含更新信息的任务数据传输对象</param>
/// <returns>更新后的任务 DTO</returns>
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
/// <summary>
/// 切换任务完成状态
/// </summary>
/// <param name="id">任务唯一标识符</param>
/// <returns>更新状态后的任务 DTO</returns>
Task<TaskDto> ToggleCompleteAsync(int id);
/// <summary>
/// 删除任务
/// </summary>
/// <param name="id">要删除的任务唯一标识符</param>
Task DeleteTaskAsync(int id);
/// <summary>
/// 获取子任务列表
/// </summary>
/// <param name="parentTaskId">父任务唯一标识符</param>
/// <returns>该父任务下的所有子任务列表</returns>
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
}
@@ -0,0 +1,61 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260313044926_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.Property<bool>("IsCompleted")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<int>("Priority")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.ToTable("Tasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Tasks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Priority = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 1),
IsCompleted = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')"),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false, defaultValueSql: "datetime('now')")
},
constraints: table =>
{
table.PrimaryKey("PK_Tasks", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Tasks");
}
}
}
@@ -0,0 +1,81 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
[Migration("20260313092658_AddParentTaskId")]
partial class AddParentTaskId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.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<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValueSql("datetime('now')");
b.HasKey("Id");
b.HasIndex("ParentTaskId");
b.ToTable("Tasks");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.HasOne("Hua.Todo.Core.Entities.TaskEntity", "ParentTask")
.WithMany("SubTasks")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("ParentTask");
});
modelBuilder.Entity("Hua.Todo.Core.Entities.TaskEntity", b =>
{
b.Navigation("SubTasks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddParentTaskId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParentTaskId",
table: "Tasks",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks",
column: "ParentTaskId",
principalTable: "Tasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_ParentTaskId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "ParentTaskId",
table: "Tasks");
}
}
}
@@ -0,0 +1,198 @@
// <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("20260406172936_AddCloudSyncCoreEntities")]
partial class AddCloudSyncCoreEntities
{
/// <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<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<DateTime>("CreatedAt")
.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<DateTime>("UpdatedAt")
.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<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.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<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("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,136 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddCloudSyncCoreEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "UserId",
table: "Tasks",
type: "TEXT",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000001"));
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.InsertData(
table: "Users",
columns: new[] { "Id", "UserName", "PasswordHash", "Role" },
values: new object[] { new Guid("00000000-0000-0000-0000-000000000001"), "local", "", "local" });
migrationBuilder.CreateTable(
name: "SecurityPolicies",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
AllowPersist = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SecurityPolicies", x => x.Id);
table.ForeignKey(
name: "FK_SecurityPolicies_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserSessions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
ExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StepUpExpiresAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserSessions", x => x.Id);
table.ForeignKey(
name: "FK_UserSessions_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tasks_UserId",
table: "Tasks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_SecurityPolicies_UserId",
table: "SecurityPolicies",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Users_UserName",
table: "Users",
column: "UserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserSessions_UserId",
table: "UserSessions",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks",
column: "UserId",
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tasks_Users_UserId",
table: "Tasks");
migrationBuilder.DropTable(
name: "SecurityPolicies");
migrationBuilder.DropTable(
name: "UserSessions");
migrationBuilder.DropTable(
name: "Users");
migrationBuilder.DropIndex(
name: "IX_Tasks_UserId",
table: "Tasks");
migrationBuilder.DropColumn(
name: "UserId",
table: "Tasks");
}
}
}
@@ -0,0 +1,203 @@
// <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("20260406173734_AddAllowSyncToSecurityPolicy")]
partial class AddAllowSyncToSecurityPolicy
{
/// <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<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<DateTime>("CreatedAt")
.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<DateTime>("UpdatedAt")
.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<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.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<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("ExpiresAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime?>("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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
/// <inheritdoc />
public partial class AddAllowSyncToSecurityPolicy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowSync",
table: "SecurityPolicies",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowSync",
table: "SecurityPolicies");
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,266 @@
// <auto-generated />
using System;
using Hua.Todo.Application.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Hua.Todo.Application.Migrations
{
[DbContext(typeof(TodoDbContext))]
partial class TodoDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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,114 @@
using Hua.Todo.Core.Entities;
using System.Text.Json.Serialization;
namespace Hua.Todo.Application.Models;
/// <summary>
/// 创建任务请求 DTO。
/// </summary>
public class CreateTaskDto
{
/// <summary>
/// 任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
/// <summary>
/// 父任务 ID(用于创建子任务)。
/// </summary>
public int? ParentTaskId { get; set; }
}
/// <summary>
/// 更新任务请求 DTO。
/// </summary>
public class UpdateTaskDto
{
/// <summary>
/// 任务 ID。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 新标题(可选)。
/// </summary>
public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 新优先级(可选)。
/// </summary>
public TaskPriority? Priority { get; set; }
}
/// <summary>
/// 任务返回 DTO。
/// </summary>
public class TaskDto
{
/// <summary>
/// 任务 ID。
/// </summary>
public int Id { get; set; }
/// <summary>
/// 任务标题。
/// </summary>
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
/// <summary>
/// 任务优先级。
/// </summary>
public TaskPriority Priority { get; set; }
/// <summary>
/// 是否已完成。
/// </summary>
public bool IsCompleted { get; set; }
/// <summary>
/// 创建时间(UTC)。
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 更新时间(UTC)。
/// </summary>
public DateTime UpdatedAt { get; set; }
/// <summary>
/// 父任务 ID(可选)。
/// </summary>
public int? ParentTaskId { get; set; }
/// <summary>
/// 子任务列表。
/// </summary>
public List<TaskDto> SubTasks { get; set; } = new();
}
/// <summary>
/// 通用 API 响应包装。
/// </summary>
/// <typeparam name="T">数据类型。</typeparam>
public class ApiResponse<T>
{
/// <summary>
/// 是否成功。
/// </summary>
public bool Success { get; set; }
/// <summary>
/// 返回数据(可选)。
/// </summary>
public T? Data { get; set; }
/// <summary>
/// 提示信息。
/// </summary>
public string Message { get; set; } = string.Empty;
/// <summary>
/// 错误列表。
/// </summary>
public List<string> Errors { get; set; } = new();
}

Some files were not shown because too many files have changed in this diff Show More