Compare commits

...

10 Commits

Author SHA1 Message Date
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
163 changed files with 25339 additions and 2308 deletions
+6 -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,8 @@ 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
+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 @@
{
}
+41
View File
@@ -0,0 +1,41 @@
{
"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",
"/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"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
<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">
<Build Solution="Debug|*" Project="false" />
</Project>
<Project Path="src/Hua.Todo.Core/Hua.Todo.Core.csproj" />
<Project Path="src/Hua.Todo.Host/Hua.Todo.Host.csproj" />
<Project Path="src/Hua.Todo.Maui/Hua.Todo.Maui.csproj">
<Build Solution="Debug|*" Project="false" />
</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) 的非官方中文翻译。它不是由自由软件基金会发布的,也不具有法律效力。只有英文原文才具有法律效力。*
+188
View File
@@ -0,0 +1,188 @@
# Hua.Todo 跨平台代办管理应用
一个基于 MAUI + WebView 架构开发的跨平台代办管理应用,支持 Windows、macOS、Android、iOS 和 Linux(预览)平台。通过 HTTP API 实现前后端通信,提供轻量、高效的任务管理体验。
## 🚀 功能特点
### 核心功能
- **跨平台支持**:基于 MAUI + WebView 架构,支持 Windows、macOS、Android、iOS 和 Linux(预览)
- **任务管理**:支持创建、编辑、删除、完成状态切换
- **优先级管理**:支持高、中、低三种优先级设置,通过颜色直观区分
- **任务状态跟踪**:清晰标记任务完成状态,支持过滤查看(全部/进行中/已完成)
- **本地数据持久化**:使用 SQLite 数据库保存数据,支持完全离线使用
- **HTTP API 通信**:前后端通过 RESTful API 进行数据交互
### 技术特性
- **现代化架构**MAUI + WebView + C# 后端 + Vue.js 前端
- **分层设计**:Core(核心层)+ API(后端)+ Web(前端)
- **响应式界面**:Vue.js 3 实现的现代化用户界面
- **统一 API 设计**RESTful API 风格,支持跨域请求
## 🛠️ 技术栈
### 后端技术栈
- **开发语言**C# 10
- **框架**.NET 10
- **UI 框架**MAUI (Multi-platform App UI)
- **Web 服务器**Kestrel (ASP.NET Core 内置)
- **API 框架**ASP.NET Core Web API
- **数据访问**Entity Framework Core
- **数据库**SQLite (本地存储)
- **依赖注入**Microsoft.Extensions.DependencyInjection
### 前端技术栈
- **开发语言**TypeScript
- **框架**Vue.js 3
- **构建工具**Vite
- **HTTP 客户端**Axios
- **状态管理**Pinia
- **UI 组件库**Element Plus / Vant (移动端)
- **CSS 预处理器**SCSS
## 📦 安装与使用
### 环境要求
- **后端**
- .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.Api
dotnet restore
dotnet ef database update
dotnet run
```
API 将在 `http://localhost:5173` 启动
#### 3. 启动前端 Web
```bash
cd src/Hua.Todo.Web
npm install
npm run dev
```
前端将在 `http://localhost:5173` 启动
### 使用说明
- **添加任务**:在前端界面中输入任务内容,设置优先级,点击添加按钮
- **管理任务**:查看任务列表,支持按状态过滤(全部/进行中/已完成)
- **完成任务**:点击任务前的复选框切换完成状态
- **删除任务**:点击删除按钮移除任务
## 🔧 开发指南
### 项目结构
```
Hua.Todo/
├── docs/ # 文档目录
│ ├── 产品需求文档.md
│ ├── 产品需求文档-1.1.0.md
│ ├── 技术设计文档.md
│ └── 代码规范文档.md
├── src/ # 源代码目录
│ ├── Hua.Todo.Core/ # 核心业务逻辑层
│ │ ├── Entities/ # 实体类
│ │ │ ├── Task.cs
│ │ │ └── TaskPriority.cs
│ │ └── Interfaces/ # 接口定义
│ │ ├── ITaskRepository.cs
│ │ └── ITaskService.cs
│ ├── Hua.Todo.Api/ # 后端 API 项目
│ │ ├── Controllers/ # API 控制器
│ │ │ └── TasksController.cs
│ │ ├── Services/ # 业务服务
│ │ │ └── TaskService.cs
│ │ ├── Repositories/ # 数据访问层
│ │ │ └── TaskRepository.cs
│ │ ├── Data/ # 数据库上下文
│ │ │ ├── TodoDbContext.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Models/ # 数据模型
│ │ │ └── TaskModels.cs
│ │ ├── Program.cs # API 入口
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ ├── Hua.Todo.Web/ # 前端 Web 项目 (Vue.js)
│ │ ├── public/ # 静态资源
│ │ ├── src/
│ │ │ ├── api/ # API 调用
│ │ │ │ ├── client.ts
│ │ │ │ └── tasks.ts
│ │ │ ├── components/ # Vue 组件
│ │ │ │ ├── TaskList.vue
│ │ │ │ └── TaskItem.vue
│ │ │ ├── types/ # TypeScript 类型定义
│ │ │ │ └── task.ts
│ │ │ ├── App.vue # 根组件
│ │ │ └── main.ts # 应用入口
│ │ ├── package.json # 依赖配置
│ │ ├── vite.config.ts # Vite 配置
│ │ └── tsconfig.json # TypeScript 配置
│ └── Hua.Todo.slnx # 解决方案文件
├── .gitignore # Git 忽略文件
└── README.md # 项目说明文档
```
### API 端点
- `GET /api/tasks` - 获取任务列表
- `GET /api/tasks/{id}` - 获取单个任务
- `POST /api/tasks` - 创建任务
- `PUT /api/tasks/{id}` - 更新任务
- `PATCH /api/tasks/{id}/complete` - 切换完成状态
- `DELETE /api/tasks/{id}` - 删除任务
## 🎯 核心模块说明
### Hua.Todo.Core
核心业务逻辑层,定义领域模型和业务规则,提供核心业务接口。
### Hua.Todo.Api
后端 API 项目,提供 RESTful API 接口,处理业务逻辑,管理数据访问和持久化。
### Hua.Todo.Web
前端 Web 项目,基于 Vue.js 3 + TypeScript,提供用户界面,通过 HTTP API 与后端通信。
## 🔄 版本更新
### 版本策略
- 采用语义化版本号:`MAJOR.MINOR.PATCH`
- v1.0.0:初始 WPF 版本
- v1.1.0MAUI + WebView 跨平台版本
### v1.1.0 更新内容
- 重构为 MAUI + WebView 架构
- 实现跨平台支持
- 使用 HTTP API 进行前后端通信
- 采用 Vue.js 3 作为前端框架
- 使用 SQLite 作为本地数据库
## 🤝 贡献指南
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
---
**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();
}
}
}
+92
View File
@@ -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 是最高优先级怀疑点)
+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 功能的限制可能影响功能实现
+2 -2
View File
@@ -1,7 +1,7 @@
# TodoList 产品需求文档 (PRD)
# Hua.Todo 产品需求文档 (PRD)
## 1. 项目概述
本项目是一个基于 C# WPF (.NET 10) 开发的桌面待办事项管理应用 (TodoList)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。
本项目是一个基于 C# WPF (.NET 10) 开发的桌面代办管理应用 (Hua.Todo)。旨在提供轻量、高效的任务管理体验,特别是通过快捷键快速唤起记录功能,最大化用户的操作效率。
## 2. 技术架构
- **开发语言**: C#
+675
View File
@@ -0,0 +1,675 @@
# Hua.Todo 代码规范文档 v1.1.0
## 1. 概述
本文档定义 Hua.Todo 项目的代码规范,包括 C#、JavaScript/TypeScript、Vue.js 和其他相关技术的编码标准。遵循这些规范有助于提高代码质量、可读性和可维护性。
## 2. 通用规范
### 2.1 命名约定
- **使用有意义的名称**: 变量、函数、类名应清晰表达其用途
- **避免缩写**: 除非是广泛认知的缩写(如 ID、URL、API)
- **一致性**: 在整个项目中保持命名风格一致
### 2.2 注释规范
- **公共 API 必须添加 XML 文档注释**
- **复杂逻辑添加行内注释**
- **避免注释显而易见的代码**
- **保持注释与代码同步更新**
### 2.3 代码格式化
- **使用统一的代码格式化工具**
- **保持一致的缩进和空格**
- **每行代码不超过 120 字符**
- **文件末尾保留一个空行**
## 3. C# 代码规范
### 3.1 命名规范
#### 类和接口
```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/tasks');
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/tasks');
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
// 使用名词复数形式
[HttpGet("tasks")]
public async Task<ActionResult<List<Task>>> GetTasks()
{
}
// 使用资源 ID
[HttpGet("tasks/{id}")]
public async Task<ActionResult<Task>> GetTask(int id)
{
}
// 使用 HTTP 方法表示操作
[HttpPost("tasks")]
public async Task<ActionResult<Task>> CreateTask(CreateTaskDto dto)
{
}
[HttpPut("tasks/{id}")]
public async Task<ActionResult<Task>> UpdateTask(int id, UpdateTaskDto dto)
{
}
[HttpDelete("tasks/{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/tasks/{id}/complete 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/tasks");
// 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
```
+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)
- ✅ 创建实现对比文档
+351
View File
@@ -0,0 +1,351 @@
# Hua.Todo 技术设计文档 v1.1.0
## 1. 项目概述
本文档描述 Hua.Todo v1.1.0 的技术设计方案,包括项目文件目录结构、模块划分、技术选型和实现细节。
## 2. 技术栈
### 2.1 后端技术栈
- **开发语言**: C# 10
- **框架**: .NET 10
- **UI 框架**: MAUI (Multi-platform App UI)
- **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/ # 文档目录
│ ├── PRD.md # 产品需求文档
│ ├── PRD-1.1.0.md # v1.1.0 产品需求文档
│ ├── TechnicalDesign.md # 技术设计文档(本文件)
│ └── CodeStandards.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.Api/ # 后端 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
│ │ │ ├── Repositories/
│ │ │ │ ├── ITaskRepository.cs
│ │ │ │ └── TaskRepository.cs
│ │ │ └── Migrations/ # 数据库迁移
│ │ ├── Middleware/ # 中间件
│ │ │ └── ExceptionMiddleware.cs
│ │ ├── Extensions/ # 扩展方法
│ │ │ └── ServiceCollectionExtensions.cs
│ │ ├── Program.cs # API 入口
│ │ ├── appsettings.json # 配置文件
│ │ └── Hua.Todo.Api.csproj # API 项目文件
│ │
│ ├── 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/ # 静态资源
│ │ │ └── index.html
│ │ ├── 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 服务器启动
**关键组件**:
- `MauiProgram.cs`: 配置 MAUI 应用和依赖注入
- `App.xaml.cs`: 应用程序主入口
- `WebViewContainer`: 封装 WebView 控件
- 平台特定服务: 快捷键、通知等
### 4.2 后端 API 项目 (Hua.Todo.Api)
**职责**:
- 提供 RESTful API 接口
- 业务逻辑处理
- 数据访问和持久化
- 本地 HTTP 服务器托管
**关键组件**:
- `Controllers`: API 端点实现
- `Services`: 业务逻辑服务
- `Data`: 数据访问层和数据库上下文
- `Program.cs`: API 服务器配置和启动
### 4.3 核心业务层 (Hua.Todo.Core)
**职责**:
- 定义领域模型和业务规则
- 提供核心业务接口
- 实现领域驱动设计模式
**关键组件**:
- `Entities`: 领域实体
- `Interfaces`: 业务接口定义
- `ValueObjects`: 值对象
- `Specifications`: 业务规范
### 4.4 前端 Web 项目 (Hua.Todo.Web)
**职责**:
- 用户界面展示
- 用户交互处理
- HTTP API 调用
- 状态管理
**关键组件**:
- `components`: Vue 组件
- `api`: API 调用封装
- `stores`: 状态管理
- `composables`: 组合式函数
## 5. HTTP API 设计
### 5.1 API 基础配置
- **基础 URL**: `http://localhost:5000/api`
- **数据格式**: JSON
- **认证方式**: 暂无(本地应用)
- **跨域配置**: 允许本地跨域请求
### 5.2 API 端点设计
#### 任务管理 API
```
GET /api/tasks # 获取任务列表
GET /api/tasks/{id} # 获取单个任务
POST /api/tasks # 创建任务
PUT /api/tasks/{id} # 更新任务
DELETE /api/tasks/{id} # 删除任务
PATCH /api/tasks/{id}/complete # 标记任务完成
```
#### 设置管理 API
```
GET /api/settings # 获取设置
PUT /api/settings # 更新设置
```
#### 同步 API
```
POST /api/sync/pull # 拉取远程数据
POST /api/sync/push # 推送本地数据
```
## 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**: 代码签名和公证
- **移动端**: 应用商店发布配置
## 9. 性能优化
### 9.1 前端优化
- 组件懒加载
- 虚拟滚动(长列表)
- 图片懒加载
- 缓存策略
### 9.2 后端优化
- 数据库查询优化
- 响应缓存
- 异步处理
- 连接池管理
## 10. 安全考虑
### 10.1 本地安全
- 本地服务器仅监听 localhost
- 防止外部访问
- 数据加密存储(可选)
### 10.2 数据安全
- 数据库文件权限控制
- 定期备份机制
- 敏感数据保护
## 11. 测试策略
### 11.1 单元测试
- 核心业务逻辑测试
- 服务层测试
- 工具函数测试
### 11.2 集成测试
- API 集成测试
- 数据库集成测试
- 前后端集成测试
### 11.3 端到端测试
- 跨平台功能测试
- 用户流程测试
- 性能测试
+62 -2
View File
@@ -1,2 +1,62 @@
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
$ErrorActionPreference = "Stop"
$ScriptPath = $PSScriptRoot
$ProjectDir = Join-Path $ScriptPath "src\Hua.Todo.Maui"
$ProjectFile = Join-Path $ProjectDir "Hua.Todo.Maui.csproj"
$SetupScript = Join-Path $ProjectDir "setup.iss"
# Read version from project file
$currentVersion = "1.0.0"
[xml]$csproj = Get-Content $ProjectFile -Raw
$versionNode = $csproj.SelectSingleNode("//Version")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
} else {
$versionNode = $csproj.SelectSingleNode("//ApplicationDisplayVersion")
if ($null -ne $versionNode) {
$currentVersion = $versionNode.InnerText
}
}
# Update setup script version with current version before build
if (Test-Path $SetupScript) {
$issContent = Get-Content $SetupScript
$versionFound = $false
for ($i = 0; $i -lt $issContent.Count; $i++) {
if ($issContent[$i] -like '#define MyAppVersion *') {
$issContent[$i] = '#define MyAppVersion "' + $currentVersion + '"'
$versionFound = $true
break
}
}
if ($versionFound) {
Set-Content $SetupScript -Value $issContent
}
}
Write-Host "Building Hua.Todo.Maui (Release)..." -ForegroundColor Cyan
dotnet publish $ProjectFile -f net10.0-windows10.0.19041.0 -c Release --self-contained false
if ($LASTEXITCODE -ne 0) {
Write-Error "MAUI build failed"
exit 1
}
$ISCC = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (Test-Path $ISCC) {
& $ISCC $SetupScript
if ($LASTEXITCODE -eq 0) {
Write-Host "Setup package created successfully!" -ForegroundColor Green
} else {
Write-Error "Packaging failed"
}
} else {
Write-Error "Inno Setup compiler not found"
}
$versionParts = $currentVersion.Split(".")
$patch = [int]$versionParts[2] + 1
$newVersion = $versionParts[0] + "." + $versionParts[1] + "." + $patch
$content = Get-Content $ProjectFile -Raw
$content = $content -replace "<Version>.*</Version>", "<Version>$newVersion</Version>"
Set-Content $ProjectFile -Value $content
+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,34 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Application.Data;
public class TodoDbContext : DbContext
{
public TodoDbContext(DbContextOptions<TodoDbContext> options) : base(options)
{
}
public DbSet<TaskEntity> Tasks { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TaskEntity>(entity =>
{
entity.ToTable("Tasks");
entity.HasKey(e => e.Id);
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Priority).HasDefaultValue(TaskPriority.Medium);
entity.Property(e => e.IsCompleted).HasDefaultValue(false);
entity.Property(e => e.CreatedAt).HasDefaultValueSql("datetime('now')");
entity.Property(e => e.UpdatedAt).HasDefaultValueSql("datetime('now')");
entity.HasOne(e => e.ParentTask)
.WithMany(e => e.SubTasks)
.HasForeignKey(e => e.ParentTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Builder;
namespace Hua.Todo.Application.DynamicApi;
public static class DynamicApiExtensions
{
public static IApplicationBuilder UseDynamicApi(this IApplicationBuilder builder)
{
return builder.UseMiddleware<DynamicApiMiddleware>();
}
}
@@ -0,0 +1,637 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Interfaces;
namespace Hua.Todo.Application.DynamicApi;
public class DynamicApiMiddleware
{
private readonly RequestDelegate _next;
private readonly IServiceProvider _serviceProvider;
private static readonly Dictionary<string, Type> _serviceTypeCache = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
static DynamicApiMiddleware()
{
InitializeServiceTypeCache();
}
private static void InitializeServiceTypeCache()
{
var assembly = typeof(IDynamicApiService).Assembly;
var serviceTypes = assembly.GetTypes()
.Where(t => t.IsInterface && typeof(IDynamicApiService).IsAssignableFrom(t))
.ToList();
foreach (var type in serviceTypes)
{
var cleanName = type.Name.EndsWith("Service")
? type.Name.Substring(0, type.Name.Length - "Service".Length)
: type.Name;
if (cleanName.StartsWith("I") && cleanName.Length > 1 && char.IsUpper(cleanName[1]))
{
cleanName = cleanName.Substring(1);
}
if (cleanName.EndsWith("App"))
{
cleanName = cleanName.Substring(0, cleanName.Length - "App".Length);
}
_serviceTypeCache[cleanName] = type;
}
}
public DynamicApiMiddleware(RequestDelegate next, IServiceProvider serviceProvider)
{
_next = next;
_serviceProvider = serviceProvider;
}
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value ?? string.Empty;
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2 || segments[0] != "api")
{
await _next(context);
return;
}
var serviceName = segments[1];
var serviceType = FindDynamicApiService(serviceName);
if (serviceType == null)
{
await _next(context);
return;
}
if (!IsRemoteServiceEnabled(serviceType))
{
await _next(context);
return;
}
using var scope = _serviceProvider.CreateScope();
var scopedServiceProvider = scope.ServiceProvider;
var service = scopedServiceProvider.GetService(serviceType);
if (service == null)
{
await _next(context);
return;
}
var method = FindMethod(serviceType, context.Request.Method, segments.Skip(2).ToArray(), context);
if (method == null)
{
await _next(context);
return;
}
if (!IsRemoteServiceEnabled(method))
{
await _next(context);
return;
}
try
{
var result = await InvokeMethod(service, method, context);
await WriteResponse(context, result, null, method.Name);
}
catch (Exception ex)
{
await WriteResponse(context, null, ex, method.Name);
}
}
private Type? FindDynamicApiService(string serviceName)
{
_serviceTypeCache.TryGetValue(serviceName, out var serviceType);
return serviceType;
}
private bool IsRemoteServiceEnabled(Type type)
{
var attribute = type.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private bool IsRemoteServiceEnabled(MethodInfo method)
{
var attribute = method.GetCustomAttribute<RemoteServiceAttribute>();
return attribute == null || attribute.IsEnabled;
}
private MethodInfo? FindMethod(Type serviceType, string httpMethod, string[] pathSegments, HttpContext context)
{
var methods = serviceType.GetMethods(BindingFlags.Public | BindingFlags.Instance);
var matchedMethods = methods.Where(m => IsRemoteServiceEnabled(m)).ToList();
// First, try to handle special case: /api/task/13/toggle
if (pathSegments.Length >= 2 && httpMethod == "PATCH")
{
var potentialMethodName = pathSegments[1];
var potentialParamValue = pathSegments[0];
// Try to find method with matching name and single parameter
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType))
{
// Try to match with normalized method name
var normalizedMethodName = method.Name;
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
// For ToggleCompleteAsync, try to match with "toggle"
if (normalizedMethodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) &&
potentialMethodName.Equals("toggle", StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
}
// Try to match with method name from path segments
var methodName = pathSegments.Length > 0 ? pathSegments[0] : null;
if (!string.IsNullOrEmpty(methodName))
{
var exactMatch = matchedMethods.FirstOrDefault(m =>
m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase) &&
MatchesHttpMethod(m, httpMethod));
if (exactMatch != null)
return exactMatch;
// Try to match method names with different naming conventions
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
// For GetSubTasksAsync, try to match with "subtasks"
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(methodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
// For GetActiveTasksAsync, try to match with "active"
if (normalizedMethodName.Equals($"{methodName}Tasks", StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
// First, try to handle special case: /api/task/13/subtasks
if (pathSegments.Length >= 2)
{
var potentialMethodName = pathSegments[1];
var potentialParamValue = pathSegments[0];
// Try to find method with matching name and single parameter
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length == 1 && IsSimpleType(parameters[0].ParameterType))
{
// Try to match with normalized method name
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
if (normalizedMethodName.Equals(potentialMethodName, StringComparison.OrdinalIgnoreCase))
{
return method;
}
}
}
}
}
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod) && MatchesRoute(method, pathSegments))
{
return method;
}
}
// Try to match methods with parameters from path
foreach (var method in matchedMethods)
{
if (MatchesHttpMethod(method, httpMethod))
{
var parameters = method.GetParameters();
if (parameters.Length > 0 && pathSegments.Length > 0)
{
// Check if all path segments can be mapped to parameters
bool canMapParameters = true;
for (int i = 0; i < pathSegments.Length; i++)
{
if (i >= parameters.Length)
{
canMapParameters = false;
break;
}
if (!IsSimpleType(parameters[i].ParameterType))
{
canMapParameters = false;
break;
}
}
if (canMapParameters)
{
// Try to convert the first path segment to the parameter type
// to avoid trying to convert non-numeric strings to numbers
try
{
ConvertValue(pathSegments[0], parameters[0].ParameterType);
return method;
}
catch
{
// If conversion fails, skip this method
continue;
}
}
}
}
}
return null;
}
private bool MatchesHttpMethod(MethodInfo method, string httpMethod)
{
if (method.GetCustomAttribute<HttpGetAttribute>() != null)
return httpMethod == "GET";
if (method.GetCustomAttribute<HttpPostAttribute>() != null)
return httpMethod == "POST";
if (method.GetCustomAttribute<HttpPutAttribute>() != null)
return httpMethod == "PUT";
if (method.GetCustomAttribute<HttpDeleteAttribute>() != null)
return httpMethod == "DELETE";
if (method.GetCustomAttribute<HttpPatchAttribute>() != null)
return httpMethod == "PATCH";
// For ToggleCompleteAsync, use PATCH method
if (method.Name.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase))
{
return httpMethod == "PATCH";
}
return GetHttpVerbByConvention(method.Name) == httpMethod;
}
private string GetHttpVerbByConvention(string methodName)
{
if (methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
return "GET";
if (methodName.StartsWith("Put", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase))
return "PUT";
if (methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Remove", StringComparison.OrdinalIgnoreCase))
return "DELETE";
if (methodName.StartsWith("Post", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) ||
methodName.StartsWith("Insert", StringComparison.OrdinalIgnoreCase))
return "POST";
if (methodName.StartsWith("Patch", StringComparison.OrdinalIgnoreCase))
return "PATCH";
return "POST";
}
private bool MatchesRoute(MethodInfo method, string[] pathSegments)
{
var httpGetAttr = method.GetCustomAttribute<HttpGetAttribute>();
var httpPostAttr = method.GetCustomAttribute<HttpPostAttribute>();
var httpPutAttr = method.GetCustomAttribute<HttpPutAttribute>();
var httpDeleteAttr = method.GetCustomAttribute<HttpDeleteAttribute>();
var httpPatchAttr = method.GetCustomAttribute<HttpPatchAttribute>();
var route = httpGetAttr?.Route ?? httpPostAttr?.Route ??
httpPutAttr?.Route ?? httpDeleteAttr?.Route ?? httpPatchAttr?.Route;
if (!string.IsNullOrEmpty(route))
{
var routeSegments = route.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (routeSegments.Length > 0 && pathSegments.Length > 0)
{
return routeSegments[0].Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase);
}
return routeSegments.Length == 0 && pathSegments.Length == 0;
}
if (pathSegments.Length == 0)
{
return true;
}
// Try to match with full method name
if (method.Name.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase))
{
return true;
}
// Try to match with normalized method name (without Get/Async prefix/suffix)
var normalizedMethodName = method.Name;
if (normalizedMethodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(3);
}
if (normalizedMethodName.EndsWith("Async", StringComparison.OrdinalIgnoreCase))
{
normalizedMethodName = normalizedMethodName.Substring(0, normalizedMethodName.Length - 5);
}
return normalizedMethodName.Equals(pathSegments[0], StringComparison.OrdinalIgnoreCase);
}
private async Task<object?> InvokeMethod(object service, MethodInfo method, HttpContext context)
{
var parameters = method.GetParameters();
var args = new List<object?>();
foreach (var param in parameters)
{
var fromBodyAttr = param.GetCustomAttribute<FromBodyAttribute>();
var fromQueryAttr = param.GetCustomAttribute<FromQueryAttribute>();
if (fromBodyAttr != null)
{
var dto = await ReadDtoFromBody(context, param.ParameterType);
args.Add(dto);
}
else if (fromQueryAttr != null || IsSimpleType(param.ParameterType))
{
var value = BindParameterFromQueryOrPath(param, context);
args.Add(value);
}
else
{
var dto = await ReadDtoFromBody(context, param.ParameterType);
args.Add(dto);
}
}
var result = method.Invoke(service, args.ToArray());
if (result is not Task task) return result;
await task;
return task.GetType().GetProperty("Result")?.GetValue(task);
}
private bool IsSimpleType(Type type)
{
return type.IsPrimitive ||
type == typeof(string) ||
type == typeof(decimal) ||
type == typeof(DateTime) ||
type == typeof(Guid) ||
Nullable.GetUnderlyingType(type) != null;
}
private object? BindParameterFromQueryOrPath(ParameterInfo param, HttpContext context)
{
var paramName = param.Name ?? string.Empty;
// Try to get value from path
var pathValue = GetPathValue(context, paramName);
if (!string.IsNullOrEmpty(pathValue))
{
return ConvertValue(pathValue, param.ParameterType);
}
// Try to get value from query string
var queryValue = context.Request.Query[paramName].FirstOrDefault();
if (!string.IsNullOrEmpty(queryValue))
{
return ConvertValue(queryValue, param.ParameterType);
}
// For methods with single parameter, try to get value from path segments
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments != null && segments.Length >= 3)
{
// For GET /api/task/13, segments = ["api", "task", "13"]
// For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"]
var methodName = segments.Length > 3 ? segments[3] : null;
var paramValue = segments[2];
// Check if this is a method with single parameter
var method = param.Member as MethodInfo;
if (method != null && method.GetParameters().Length == 1)
{
return ConvertValue(paramValue, param.ParameterType);
}
// Special case for GetSubTasksAsync
if (methodName?.Equals("subtasks", StringComparison.OrdinalIgnoreCase) == true &&
paramName.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase))
{
return ConvertValue(paramValue, param.ParameterType);
}
}
if (param.ParameterType.IsValueType && Nullable.GetUnderlyingType(param.ParameterType) == null)
{
throw new ArgumentException($"Parameter '{paramName}' is required");
}
return null;
}
private object? ConvertValue(string value, Type targetType)
{
try
{
if (targetType == typeof(string))
return value;
if (targetType == typeof(int) || targetType == typeof(int?))
return int.Parse(value);
if (targetType == typeof(long) || targetType == typeof(long?))
return long.Parse(value);
if (targetType == typeof(bool) || targetType == typeof(bool?))
return bool.Parse(value);
if (targetType == typeof(decimal) || targetType == typeof(decimal?))
return decimal.Parse(value);
if (targetType == typeof(DateTime) || targetType == typeof(DateTime?))
return DateTime.Parse(value);
if (targetType == typeof(Guid) || targetType == typeof(Guid?))
return Guid.Parse(value);
return Convert.ChangeType(value, targetType);
}
catch
{
throw new ArgumentException($"Cannot convert '{value}' to {targetType.Name}");
}
}
private string? GetPathValue(HttpContext context, string? paramName)
{
var segments = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments == null || segments.Length < 3)
return null;
// Try to find parameter in path segments
// For GET /api/task/13, segments = ["api", "task", "13"]
// For GET /api/task/13/subtasks, segments = ["api", "task", "13", "subtasks"]
if (segments.Length >= 3)
{
// First check if paramName is "id" (common case)
if (paramName?.Equals("id", StringComparison.OrdinalIgnoreCase) == true)
return segments[2];
// Then check if paramName is "parentTaskId" (for subtasks)
if (paramName?.Equals("parentTaskId", StringComparison.OrdinalIgnoreCase) == true && segments.Length >= 3)
return segments[2];
}
return null;
}
private async Task<object?> ReadDtoFromBody(HttpContext context, Type dtoType)
{
var body = await new StreamReader(context.Request.Body).ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body))
return null;
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = {
new System.Text.Json.Serialization.JsonStringEnumConverter()
}
};
return JsonSerializer.Deserialize(body, dtoType, options);
}
private async Task WriteResponse(HttpContext context, object? result, Exception? exception, string methodName)
{
context.Response.ContentType = "application/json";
var message = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功",
_ => "操作成功"
};
var errorMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败",
_ => "操作失败"
};
// Get friendly error message
var errors = new List<string>();
if (exception != null)
{
if (exception is Microsoft.EntityFrameworkCore.DbUpdateException dbEx)
{
var innerMessage = dbEx.InnerException?.Message ?? dbEx.Message;
if (innerMessage.Contains("no such table", StringComparison.OrdinalIgnoreCase))
{
errors.Add("数据库表不存在,请尝试重启应用或重新初始化数据库。");
}
else if (innerMessage.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase))
{
errors.Add("该项已存在,请检查是否重复。");
}
else
{
errors.Add($"数据库操作失败: {innerMessage}");
}
}
else
{
errors.Add(exception.Message);
}
}
var response = new
{
Success = exception == null,
Data = result,
Message = exception == null ? message : errorMessage,
Errors = errors.Count > 0 ? errors : null
};
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
context.Response.StatusCode = exception switch
{
KeyNotFoundException => 404,
ArgumentException => 400,
_ => 200
};
await context.Response.WriteAsync(json);
}
}
@@ -0,0 +1,76 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpGetAttribute : Attribute
{
public string? Route { get; set; }
public HttpGetAttribute()
{
}
public HttpGetAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpPostAttribute : Attribute
{
public string? Route { get; set; }
public HttpPostAttribute()
{
}
public HttpPostAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpPutAttribute : Attribute
{
public string? Route { get; set; }
public HttpPutAttribute()
{
}
public HttpPutAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpDeleteAttribute : Attribute
{
public string? Route { get; set; }
public HttpDeleteAttribute()
{
}
public HttpDeleteAttribute(string route)
{
Route = route;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class HttpPatchAttribute : Attribute
{
public string? Route { get; set; }
public HttpPatchAttribute()
{
}
public HttpPatchAttribute(string route)
{
Route = route;
}
}
@@ -0,0 +1,11 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class FromQueryAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)]
public class FromBodyAttribute : Attribute
{
}
@@ -0,0 +1,8 @@
namespace Hua.Todo.Application.DynamicApi;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method, AllowMultiple = false)]
public class RemoteServiceAttribute : Attribute
{
public bool IsEnabled { get; set; } = true;
public bool IsMetadataEnabled { get; set; } = true;
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net10.0'">
<Compile Remove="DynamicApi\\**\\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Core\Hua.Todo.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,5 @@
namespace Hua.Todo.Application.Interfaces;
public interface IDynamicApiService
{
}
@@ -0,0 +1,16 @@
using Hua.Todo.Application.Models;
namespace Hua.Todo.Application.Interfaces;
public interface ITaskService : IDynamicApiService
{
Task<List<TaskDto>> GetAllTasksAsync();
Task<TaskDto?> GetTaskByIdAsync(int id);
Task<List<TaskDto>> GetActiveTasksAsync();
Task<List<TaskDto>> GetCompletedTasksAsync();
Task<TaskDto> CreateTaskAsync(CreateTaskDto dto);
Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto);
Task<TaskDto> ToggleCompleteAsync(int id);
Task DeleteTaskAsync(int id);
Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId);
}
@@ -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,78 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Hua.Todo.Application.Data;
#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.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,47 @@
using Hua.Todo.Core.Entities;
using System.Text.Json.Serialization;
namespace Hua.Todo.Application.Models;
public class CreateTaskDto
{
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; } = TaskPriority.Medium;
public int? ParentTaskId { get; set; }
}
public class UpdateTaskDto
{
public int Id { get; set; }
public string? Title { get; set; }
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority? Priority { get; set; }
}
public class TaskDto
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
public TaskPriority Priority { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public int? ParentTaskId { get; set; }
public List<TaskDto> SubTasks { get; set; } = new();
}
public class ApiResponse<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
public string Message { get; set; } = string.Empty;
public List<string> Errors { get; set; } = new();
}
@@ -0,0 +1,12 @@
{
"profiles": {
"Hua.Todo.Application": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:53852;http://localhost:53853"
}
}
}
@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application.Data;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Repositories;
public class TaskRepository : ITaskRepository
{
private readonly TodoDbContext _context;
public TaskRepository(TodoDbContext context)
{
_context = context;
}
public async Task<List<TaskEntity>> GetAllAsync()
{
return await _context.Tasks
.Include(t => t.SubTasks)
.ToListAsync();
}
public async Task<TaskEntity?> GetByIdAsync(int id)
{
return await _context.Tasks
.Include(t => t.SubTasks)
.FirstOrDefaultAsync(t => t.Id == id);
}
public async Task<List<TaskEntity>> GetActiveTasksAsync()
{
return await _context.Tasks
.Where(t => !t.IsCompleted)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
public async Task<List<TaskEntity>> GetCompletedTasksAsync()
{
return await _context.Tasks
.Where(t => t.IsCompleted)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync();
}
public async Task<TaskEntity> AddAsync(TaskEntity taskEntity)
{
_context.Tasks.Add(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
public async Task<TaskEntity> UpdateAsync(TaskEntity taskEntity)
{
taskEntity.UpdatedAt = DateTime.UtcNow;
_context.Tasks.Update(taskEntity);
await _context.SaveChangesAsync();
return taskEntity;
}
public async Task DeleteAsync(int id)
{
var task = await _context.Tasks.FindAsync(id);
if (task != null)
{
_context.Tasks.Remove(task);
await _context.SaveChangesAsync();
}
}
public async Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId)
{
return await _context.Tasks
.Where(t => t.ParentTaskId == parentTaskId)
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Data;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services;
using Hua.Todo.Core.Interfaces;
using ITaskService = Hua.Todo.Application.Interfaces.ITaskService;
namespace Hua.Todo.Application;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddApplicationServices(this IServiceCollection services, string connectionString)
{
services.AddDbContext<TodoDbContext>(options =>
options.UseSqlite(connectionString, b => b.MigrationsAssembly("Hua.Todo.Application")));
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<ITaskService, TaskService>();
return services;
}
}
@@ -0,0 +1,127 @@
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Interfaces;
namespace Hua.Todo.Application.Services;
public class TaskService : ITaskService
{
private readonly ITaskRepository _taskRepository;
public TaskService(ITaskRepository taskRepository)
{
_taskRepository = taskRepository;
}
public async Task<List<TaskDto>> GetAllTasksAsync()
{
var tasks = await _taskRepository.GetAllAsync();
return tasks.Select(MapToDto).ToList();
}
public async Task<TaskDto?> GetTaskByIdAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
return task != null ? MapToDto(task) : null;
}
public async Task<List<TaskDto>> GetActiveTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => !t.IsCompleted).Select(MapToDto).ToList();
}
public async Task<List<TaskDto>> GetCompletedTasksAsync()
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.IsCompleted).Select(MapToDto).ToList();
}
public async Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{
var task = new TaskEntity
{
Title = dto.Title,
Priority = dto.Priority,
IsCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = dto.ParentTaskId
};
var createdTask = await _taskRepository.AddAsync(task);
return MapToDto(createdTask);
}
public async Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{
var task = await _taskRepository.GetByIdAsync(dto.Id);
if (task == null)
{
throw new KeyNotFoundException($"Task with ID {dto.Id} not found");
}
if (!string.IsNullOrEmpty(dto.Title))
{
task.Title = dto.Title;
}
if (dto.Priority.HasValue)
{
task.Priority = dto.Priority.Value;
}
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask);
}
public async Task<TaskDto> ToggleCompleteAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
{
throw new KeyNotFoundException($"Task with ID {id} not found");
}
task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
var updatedTask = await _taskRepository.UpdateAsync(task);
return MapToDto(updatedTask);
}
public async Task DeleteTaskAsync(int id)
{
var task = await _taskRepository.GetByIdAsync(id);
if (task == null)
{
throw new KeyNotFoundException($"Task with ID {id} not found");
}
await _taskRepository.DeleteAsync(id);
}
public async Task<List<TaskDto>> GetSubTasksAsync(int parentTaskId)
{
var allTasks = await _taskRepository.GetAllAsync();
return allTasks.Where(t => t.ParentTaskId == parentTaskId).Select(MapToDto).ToList();
}
private TaskDto MapToDto(TaskEntity task)
{
return new TaskDto
{
Id = task.Id,
Title = task.Title,
Priority = task.Priority,
IsCompleted = task.IsCompleted,
CreatedAt = task.CreatedAt,
UpdatedAt = task.UpdatedAt,
ParentTaskId = task.ParentTaskId,
SubTasks = task.SubTasks.Select(MapToDto).ToList()
};
}
}
+52
View File
@@ -0,0 +1,52 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 任务实体类,表示一个代办
/// </summary>
public class TaskEntity
{
/// <summary>
/// 任务唯一标识符
/// </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>
/// 任务创建时间(UTC
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 任务最后更新时间(UTC
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 父任务ID,用于支持子任务功能
/// </summary>
public int? ParentTaskId { get; set; }
/// <summary>
/// 父任务导航属性
/// </summary>
public TaskEntity? ParentTask { get; set; }
/// <summary>
/// 子任务集合
/// </summary>
public List<TaskEntity> SubTasks { get; set; } = new();
}
@@ -0,0 +1,22 @@
namespace Hua.Todo.Core.Entities;
/// <summary>
/// 任务优先级枚举,定义任务的三种优先级级别
/// </summary>
public enum TaskPriority
{
/// <summary>
/// 低优先级
/// </summary>
Low = 0,
/// <summary>
/// 中优先级(默认)
/// </summary>
Medium = 1,
/// <summary>
/// 高优先级
/// </summary>
High = 2
}
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateTargetFrameworkAttribute>false</GenerateTargetFrameworkAttribute>
</PropertyGroup>
</Project>
@@ -0,0 +1,61 @@
using Hua.Todo.Core.Entities;
namespace Hua.Todo.Core.Interfaces;
/// <summary>
/// 任务仓储接口,定义任务数据访问操作
/// </summary>
public interface ITaskRepository
{
/// <summary>
/// 获取所有任务
/// </summary>
/// <returns>任务列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetAllAsync();
/// <summary>
/// 根据ID获取指定任务
/// </summary>
/// <param name="id">任务ID</param>
/// <returns>任务对象,如果不存在则返回null</returns>
System.Threading.Tasks.Task<TaskEntity?> GetByIdAsync(int id);
/// <summary>
/// 获取所有未完成的任务
/// </summary>
/// <returns>未完成任务列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetActiveTasksAsync();
/// <summary>
/// 获取所有已完成的任务
/// </summary>
/// <returns>已完成任务列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetCompletedTasksAsync();
/// <summary>
/// 添加新任务
/// </summary>
/// <param name="taskEntity">要添加的任务对象</param>
/// <returns>添加后的任务对象(包含生成的ID</returns>
System.Threading.Tasks.Task<TaskEntity> AddAsync(TaskEntity taskEntity);
/// <summary>
/// 更新任务
/// </summary>
/// <param name="taskEntity">要更新的任务对象</param>
/// <returns>更新后的任务对象</returns>
System.Threading.Tasks.Task<TaskEntity> UpdateAsync(TaskEntity taskEntity);
/// <summary>
/// 删除指定ID的任务
/// </summary>
/// <param name="id">任务ID</param>
System.Threading.Tasks.Task DeleteAsync(int id);
/// <summary>
/// 获取指定父任务的所有子任务
/// </summary>
/// <param name="parentTaskId">父任务ID</param>
/// <returns>子任务列表</returns>
System.Threading.Tasks.Task<List<TaskEntity>> GetSubTasksAsync(int parentTaskId);
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
</ItemGroup>
</Project>
+46
View File
@@ -0,0 +1,46 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Hua.Todo.Application;
using Hua.Todo.Application.DynamicApi;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddAuthorization();
builder.Services.AddApplicationServices("Data Source=Hua.Todo.db");
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowAll", policy =>
{
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
});
var app = builder.Build();
// Apply database migrations
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<Hua.Todo.Application.Data.TodoDbContext>();
dbContext.Database.Migrate();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("AllowAll");
app.UseAuthorization();
app.UseDynamicApi();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5173",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7175;http://localhost:5173",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Hua.Todo.Maui"
x:Class="Hua.Todo.Maui.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+293
View File
@@ -0,0 +1,293 @@
using Microsoft.Extensions.DependencyInjection;
using System;
using System.IO;
using Hua.Todo.Maui.Services;
using Hua.Todo.Maui.Views;
using Hua.Todo.Maui.Models;
#if WINDOWS
using System.Runtime.InteropServices;
using Windowing = Microsoft.UI.Windowing;
using WinUiWindow = Microsoft.UI.Xaml.Window;
using WinRT.Interop;
#endif
namespace Hua.Todo.Maui;
public partial class App : global::Microsoft.Maui.Controls.Application
{
private readonly IServiceProvider _serviceProvider;
private readonly IHotKeySettingsService _settingsService;
private readonly IGlobalHotKeyService _hotKeyService;
private readonly ISystemTrayService _trayService;
private Window? _mainWindow;
private bool _isHotkeyRegistered;
private bool _isWindowCentered;
public App(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
InitializeComponent();
_settingsService = serviceProvider.GetRequiredService<IHotKeySettingsService>();
_hotKeyService = serviceProvider.GetRequiredService<IGlobalHotKeyService>();
_trayService = serviceProvider.GetRequiredService<ISystemTrayService>();
}
protected override Microsoft.Maui.Controls.Window CreateWindow(IActivationState? activationState)
{
_mainWindow = new Microsoft.Maui.Controls.Window(_serviceProvider.GetRequiredService<MainPage>())
{
Width = 450,
Height = 640,
Title = AppMetadata.GetWindowTitle()
};
#if WINDOWS
_mainWindow.TitleBar = CreateWindowTitleBar();
#endif
_mainWindow.Destroying += (s, e) =>
{
if (_isHotkeyRegistered)
{
_hotKeyService.UnregisterHotKey();
_isHotkeyRegistered = false;
}
_trayService.Dispose();
};
_mainWindow.Created += (s, e) =>
{
_trayService.Initialize(_mainWindow, ShowMainWindow, ExitApplication);
RegisterHotkeyWhenReady();
#if WINDOWS
MainThread.BeginInvokeOnMainThread(() =>
{
if (_mainWindow.Handler?.PlatformView is WinUiWindow platformWindow)
{
// Ensure app doesn't shutdown when main window closes (we hide it)
platformWindow.AppWindow.Closing += (sender, args) =>
{
args.Cancel = true;
new Hua.Todo.Maui.Platforms.Windows.WindowsWindowService().HideWindow(_mainWindow);
};
CenterMainWindow(platformWindow);
ConfigureWindowsTitleBar(platformWindow);
}
});
#endif
};
return _mainWindow;
}
private void RegisterHotkeyWhenReady(int attempt = 0)
{
if (_mainWindow == null) return;
#if WINDOWS
if (_mainWindow.Handler?.PlatformView is not WinUiWindow)
{
if (attempt < 30)
{
_mainWindow.Dispatcher.DispatchDelayed(TimeSpan.FromMilliseconds(100), () => RegisterHotkeyWhenReady(attempt + 1));
}
return;
}
#endif
RegisterHotkey();
}
private void OnHotKeyPressed()
{
MainThread.BeginInvokeOnMainThread(() =>
{
ShowMainWindow();
});
}
private void ShowMainWindow()
{
if (_mainWindow != null)
{
_mainWindow.Dispatcher.Dispatch(() =>
{
#if WINDOWS
if (_mainWindow.Handler != null)
{
new Hua.Todo.Maui.Platforms.Windows.WindowsWindowService().RestoreWindow(_mainWindow);
var platformWindow = _mainWindow.Handler.PlatformView as WinUiWindow;
platformWindow?.Activate();
}
#else
if (global::Microsoft.Maui.Controls.Application.Current != null &&
!global::Microsoft.Maui.Controls.Application.Current.Windows.Contains(_mainWindow))
{
global::Microsoft.Maui.Controls.Application.Current.OpenWindow(_mainWindow);
}
#endif
});
}
}
private void ExitApplication()
{
_trayService?.Dispose();
global::Microsoft.Maui.Controls.Application.Current?.Quit();
}
private void RegisterHotkey()
{
if (_hotKeyService == null || _settingsService == null) return;
var config = _settingsService.GetConfig();
if (_hotKeyService.IsSupported && config.IsEnabled)
{
_hotKeyService.RegisterHotKey(config.Modifiers, config.Key, OnHotKeyPressed);
_isHotkeyRegistered = true;
config.PropertyChanged += (s, args) =>
{
if (args.PropertyName == nameof(HotKeyConfig.Modifiers) ||
args.PropertyName == nameof(HotKeyConfig.Key) ||
args.PropertyName == nameof(HotKeyConfig.IsEnabled))
{
if (config.IsEnabled && _hotKeyService.IsSupported)
{
_hotKeyService.UpdateHotKey(config.Modifiers, config.Key);
}
else
{
_hotKeyService.UnregisterHotKey();
_isHotkeyRegistered = false;
}
}
};
}
}
#if WINDOWS
private void ConfigureWindowsTitleBar(WinUiWindow platformWindow)
{
var title = AppMetadata.GetWindowTitle();
platformWindow.Title = title;
if (_mainWindow != null)
{
_mainWindow.Title = title;
}
var appWindow = platformWindow.AppWindow;
if (appWindow != null)
{
appWindow.Title = title;
var hWnd = WindowNative.GetWindowHandle(platformWindow);
if (hWnd != IntPtr.Zero)
{
SetWindowText(hWnd, title);
}
var iconPath = Path.Combine(AppContext.BaseDirectory, "icon.ico");
if (File.Exists(iconPath))
{
appWindow.SetIcon(iconPath);
}
var titleBar = appWindow.TitleBar;
titleBar.IconShowOptions = Windowing.IconShowOptions.ShowIconAndSystemMenu;
titleBar.BackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.InactiveBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonInactiveBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonHoverBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonPressedBackgroundColor = Microsoft.UI.Colors.Transparent;
titleBar.ButtonForegroundColor = Microsoft.UI.Colors.Black;
titleBar.ButtonInactiveForegroundColor = Microsoft.UI.Colors.Black;
titleBar.ButtonHoverForegroundColor = Microsoft.UI.Colors.Black;
titleBar.ButtonPressedForegroundColor = Microsoft.UI.Colors.Black;
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "SetWindowTextW")]
private static extern bool SetWindowText(IntPtr hWnd, string lpString);
private static TitleBar CreateWindowTitleBar()
{
return new TitleBar
{
BackgroundColor = Microsoft.Maui.Graphics.Color.FromArgb("#F5F5F5"),
Icon = string.Empty,
Title = string.Empty,
ForegroundColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"),
LeadingContent = new HorizontalStackLayout
{
Spacing = 4,
Padding = new Microsoft.Maui.Thickness(10, 0, 0, 0),
VerticalOptions = LayoutOptions.Center,
Children =
{
new Image
{
Source = "icon.jpg",
WidthRequest = 22,
HeightRequest = 22,
VerticalOptions = LayoutOptions.Center
},
new Label
{
Text = AppMetadata.GetTitleBarVersionText(),
FontFamily = "Microsoft YaHei UI",
TextColor = Microsoft.Maui.Graphics.Color.FromArgb("#333333"),
VerticalTextAlignment = Microsoft.Maui.TextAlignment.Center,
VerticalOptions = LayoutOptions.Center,
FontSize = 14
}
}
}
};
}
private void CenterMainWindow(WinUiWindow platformWindow)
{
if (_isWindowCentered) return;
var appWindow = platformWindow.AppWindow;
if (appWindow == null) return;
var displayArea = Windowing.DisplayArea.GetFromWindowId(
appWindow.Id,
Windowing.DisplayAreaFallback.Primary);
var workArea = displayArea.WorkArea;
var windowWidthPx = appWindow.Size.Width;
var windowHeightPx = appWindow.Size.Height;
if (windowWidthPx <= 0 || windowHeightPx <= 0)
{
var scale = platformWindow.Content?.XamlRoot?.RasterizationScale ?? 1.0;
windowWidthPx = windowWidthPx <= 0 ? (int)Math.Round((_mainWindow?.Width ?? 450) * scale) : windowWidthPx;
windowHeightPx = windowHeightPx <= 0 ? (int)Math.Round((_mainWindow?.Height ?? 640) * scale) : windowHeightPx;
}
if (windowWidthPx <= 0 || windowHeightPx <= 0) return;
var x = workArea.X + (workArea.Width - windowWidthPx) / 2;
var y = workArea.Y + (workArea.Height - windowHeightPx) / 2;
if (windowWidthPx >= workArea.Width) x = workArea.X;
if (windowHeightPx >= workArea.Height) y = workArea.Y;
x = Math.Max(workArea.X, Math.Min(x, workArea.X + workArea.Width - windowWidthPx));
y = Math.Max(workArea.Y, Math.Min(y, workArea.Y + workArea.Height - windowHeightPx));
appWindow.Move(new Windows.Graphics.PointInt32(x, y));
_isWindowCentered = true;
}
#endif
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="Hua.Todo.Maui.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Hua.Todo.Maui"
xmlns:views="clr-namespace:Hua.Todo.Maui.Views"
Title="Hua.Todo.Maui">
<ShellContent
Title="Home"
Route="MainPage" />
</Shell>
+9
View File
@@ -0,0 +1,9 @@
namespace Hua.Todo.Maui;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
}
@@ -0,0 +1,28 @@
using Microsoft.Maui.Controls;
using System.Globalization;
namespace Hua.Todo.Maui.Converters
{
public class PriorityToColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string priority)
{
return priority switch
{
"高" => Color.FromRgb(255, 205, 210),
"中" => Color.FromRgb(255, 224, 178),
"低" => Color.FromRgb(200, 230, 201),
_ => Colors.White
};
}
return Colors.White;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
+191
View File
@@ -0,0 +1,191 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>Hua.Todo.Maui</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<CodePage>65001</CodePage>
<!-- Android SDK Path - Fallback for local development -->
<AndroidSdkDirectory Condition="'$(AndroidSdkDirectory)' == '' and Exists('$(USERPROFILE)\AppData\Local\Android\Sdk')">$(USERPROFILE)\AppData\Local\Android\Sdk</AndroidSdkDirectory>
<!-- Display name -->
<ApplicationTitle>代办</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.Hua.Todo.maui</ApplicationId>
<!-- Versions -->
<Version>1.1.5</Version>
<ApplicationDisplayVersion>$(Version)</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- Assembly Info -->
<AssemblyTitle>待办</AssemblyTitle>
<AssemblyProduct>待办</AssemblyProduct>
<AssemblyCompany>Hua.Todo</AssemblyCompany>
<AssemblyCopyright>Copyright 2024</AssemblyCopyright>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<TodoWebDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../Hua.Todo.Web'))</TodoWebDir>
<TodoWebDistDir>$(TodoWebDir)\dist</TodoWebDistDir>
<SkipWebBuild>false</SkipWebBuild>
<ForceWebBuild>false</ForceWebBuild>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<AndroidPackageFormat>aab</AndroidPackageFormat>
<AndroidUseAapt2>True</AndroidUseAapt2>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
<Optimize>False</Optimize>
<EnableMauiImageProcessing>false</EnableMauiImageProcessing>
<DisableResizetizer>true</DisableResizetizer>
<AndroidPackageFormat>apk</AndroidPackageFormat>
<AndroidCreatePackagePerAbi>False</AndroidCreatePackagePerAbi>
<AndroidUseSharedRuntime>True</AndroidUseSharedRuntime>
<AndroidEnableFastDeployment>True</AndroidEnableFastDeployment>
<AndroidFastDeploymentType>Assemblies</AndroidFastDeploymentType>
<EmbedAssembliesIntoApk>False</EmbedAssembliesIntoApk>
<AndroidSupportedAbis>x86_64</AndroidSupportedAbis>
<UseAppHost>false</UseAppHost>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<Optimize>False</Optimize>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Include="icon.jpg" Resize="True" BaseSize="256,256" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
<MauiAsset Include="appsettings.json" LogicalName="appsettings.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Hua.Todo.Application\Hua.Todo.Application.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="icon.ico" />
</ItemGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<Content Include="icon.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0'">
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.1721" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="System.Drawing.Common" Version="10.0.5" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="$(MauiVersion)" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<Target Name="BuildTodoWeb" BeforeTargets="UpdateAndroidAssets;BeforeBuild" Condition="'$(SkipWebBuild)' != 'true' And Exists('$(TodoWebDir)') And ('$(ForceWebBuild)' == 'true' Or !Exists('$(TodoWebDistDir)\index.html'))">
<Exec Command="npm ci" WorkingDirectory="$(TodoWebDir)" Condition="Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm install" WorkingDirectory="$(TodoWebDir)" Condition="!Exists('$(TodoWebDir)\package-lock.json') And !Exists('$(TodoWebDir)\node_modules')" />
<Exec Command="npm run build" WorkingDirectory="$(TodoWebDir)" />
</Target>
<Target Name="SyncTodoWebDistToMauiWwwroot" BeforeTargets="ProcessMauiAssets" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-android' And Exists('$(TodoWebDistDir)\index.html')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(MSBuildProjectDirectory)\wwwroot" Condition="Exists('$(MSBuildProjectDirectory)\wwwroot')" />
<MakeDir Directories="$(MSBuildProjectDirectory)\wwwroot" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(MSBuildProjectDirectory)\wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
<ItemGroup>
<MauiAsset Include="@(_TodoWebDistFiles)">
<LogicalName>wwwroot/%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</MauiAsset>
</ItemGroup>
</Target>
<Target Name="CopyTodoWebDistToWindowsWwwroot" BeforeTargets="Build" DependsOnTargets="BuildTodoWeb" Condition="'$(TargetFramework)' == 'net10.0-windows10.0.19041.0' And Exists('$(TodoWebDistDir)')">
<ItemGroup>
<_TodoWebDistFiles Include="$(TodoWebDistDir)\**\*" />
</ItemGroup>
<RemoveDir Directories="$(TargetDir)wwwroot" Condition="Exists('$(TargetDir)wwwroot')" />
<MakeDir Directories="$(TargetDir)wwwroot" />
<Copy SourceFiles="@(_TodoWebDistFiles)" DestinationFiles="@(_TodoWebDistFiles->'$(TargetDir)wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=wwwroot/@EntryIndexedValue">False</s:Boolean></wpf:ResourceDictionary>
+167
View File
@@ -0,0 +1,167 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Data.Sqlite;
using Hua.Todo.Application;
using Hua.Todo.Application.Data;
using Hua.Todo.Maui.Models;
using Hua.Todo.Maui.Services;
using Hua.Todo.Maui.Services.Platforms;
namespace Hua.Todo.Maui;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
var appSettings = LoadAppSettings();
// Set default connection string if not provided
if (string.IsNullOrEmpty(appSettings.WebServer.ConnectionString))
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dbDir = Path.Combine(localAppData, "Hua.Todo");
if (!Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
var dbPath = Path.Combine(dbDir, "Hua.Todo.db");
appSettings.WebServer.ConnectionString = $"Data Source={dbPath};Cache=Shared";
}
builder.Services.AddSingleton(appSettings);
var connectionString = appSettings.WebServer.ConnectionString;
builder.Services.AddApplicationServices(connectionString);
builder.Services.AddTransient<Views.MainPage>();
builder.Services.AddSingleton<IHotKeySettingsService>(sp =>
new HotKeySettingsService(sp.GetRequiredService<AppSettings>()));
builder.Services.AddSingleton<IGlobalHotKeyService>(sp => GlobalHotKeyServiceFactory.Create());
builder.Services.AddSingleton<ISystemTrayService>(sp =>
{
#if WINDOWS
return new WindowsSystemTrayService();
#else
return new NullSystemTrayService();
#endif
});
#if WINDOWS
builder.Services.AddSingleton<IEmbeddedWebServerService, EmbeddedWebServerService>();
#elif ANDROID
builder.Services.AddSingleton<IEmbeddedWebServerService, MobileEmbeddedWebServerService>();
#else
builder.Services.AddSingleton<IEmbeddedWebServerService, NoopEmbeddedWebServerService>();
#endif
#if DEBUG
builder.Logging.AddDebug();
#endif
var app = builder.Build();
_ = Task.Run(async () =>
{
try
{
InitializeDatabase(app.Services, connectionString);
await StartWebServer(app.Services);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"App initialization failed: {ex}");
}
});
return app;
}
private static AppSettings LoadAppSettings()
{
try
{
using var stream = FileSystem.OpenAppPackageFileAsync("appsettings.json").GetAwaiter().GetResult();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
try
{
var settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(settingsPath))
{
return new AppSettings();
}
var json = File.ReadAllText(settingsPath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
catch
{
return new AppSettings();
}
}
}
private static void InitializeDatabase(IServiceProvider services, string connectionString)
{
using var scope = services.CreateScope();
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
var sqliteBuilder = new SqliteConnectionStringBuilder(connectionString);
var actualDbPath = sqliteBuilder.DataSource;
if (!string.IsNullOrEmpty(actualDbPath))
{
var dbDir = Path.GetDirectoryName(actualDbPath);
if (!string.IsNullOrEmpty(dbDir) && !Directory.Exists(dbDir))
{
Directory.CreateDirectory(dbDir);
}
}
// Ensure WAL mode to avoid locking issues
dbContext.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;");
dbContext.Database.Migrate();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Database initialization failed: {ex.Message}");
try
{
var context = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
context.Database.EnsureCreated();
}
catch
{
}
}
}
private static async Task StartWebServer(IServiceProvider services)
{
try
{
var webServer = services.GetRequiredService<IEmbeddedWebServerService>();
await webServer.StartAsync();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Web server start failed: {ex}");
}
}
}
+44
View File
@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace Hua.Todo.Maui.Models;
public class AppSettings
{
[JsonPropertyName("WebServer")]
public WebServerSettings WebServer { get; set; } = new();
[JsonPropertyName("HotKey")]
public HotKeyDefaultSettings HotKey { get; set; } = new();
}
public class WebServerSettings
{
[JsonPropertyName("Port")]
public int Port { get; set; } = 5057;
[JsonPropertyName("IsUsingStatic")]
public bool IsUsingStatic { get; set; } = true;
[JsonPropertyName("ConnectionString")]
public string ConnectionString { get; set; } = "";
[JsonPropertyName("HostUrl")]
public string HostUrl { get; set; } = "http://localhost:5057";
[JsonPropertyName("ForEndUrl")]
public string ForEndUrl { get; set; } = "http://localhost:5174";
}
public class HotKeyDefaultSettings
{
[JsonPropertyName("DefaultModifiers")]
public string DefaultModifiers { get; set; } = "Alt";
[JsonPropertyName("DefaultKey")]
public string DefaultKey { get; set; } = "X";
[JsonPropertyName("DefaultIsEnabled")]
public bool DefaultIsEnabled { get; set; } = true;
}
+70
View File
@@ -0,0 +1,70 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace Hua.Todo.Maui.Models
{
/// <summary>
/// 热键配置模型类
/// </summary>
public class HotKeyConfig : INotifyPropertyChanged
{
private string _modifiers = "Alt";
private string _key = "X";
private bool _isEnabled = true;
/// <summary>
/// 修饰键(如 Alt, Control, Shift 等)
/// </summary>
public string Modifiers
{
get => _modifiers;
set
{
if (_modifiers != value)
{
_modifiers = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 主键(如 X, C, V 等)
/// </summary>
public string Key
{
get => _key;
set
{
if (_key != value)
{
_key = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// 是否启用热键
/// </summary>
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
OnPropertyChanged();
}
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace Hua.Todo.Maui.Models
{
public class QuickEntryData
{
[JsonPropertyName("action")]
public string Action { get; set; } = string.Empty;
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("priority")]
public string Priority { get; set; } = "Medium";
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace Hua.Todo.Maui.Models
{
/// <summary>
/// 代办模型类
/// </summary>
public class TodoItem
{
/// <summary>
/// 任务内容
/// </summary>
public string Content { get; set; } = string.Empty;
/// <summary>
/// 任务优先级
/// </summary>
public string Priority { get; set; } = "中";
/// <summary>
/// 任务是否已完成
/// </summary>
public bool IsCompleted { get; set; }
}
}
@@ -0,0 +1,135 @@
using Android.Content.Res;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
namespace Hua.Todo.Maui.Platforms.Android;
public sealed class AndroidAssetFileProvider : IFileProvider
{
private readonly AssetManager _assets;
private readonly string _root;
public AndroidAssetFileProvider(AssetManager assets, string root)
{
_assets = assets;
_root = NormalizePath(root).TrimEnd('/');
}
public IFileInfo GetFileInfo(string subpath)
{
var assetPath = Combine(_root, subpath);
if (string.IsNullOrEmpty(assetPath))
{
return new NotFoundFileInfo(subpath);
}
try
{
using var stream = _assets.Open(assetPath);
return new AndroidAssetFileInfo(_assets, assetPath, Path.GetFileName(assetPath), false);
}
catch (global::Java.IO.FileNotFoundException)
{
return new NotFoundFileInfo(subpath);
}
catch
{
return new NotFoundFileInfo(subpath);
}
}
public IDirectoryContents GetDirectoryContents(string subpath)
{
var dirPath = Combine(_root, subpath).TrimEnd('/');
if (string.IsNullOrEmpty(dirPath))
{
return NotFoundDirectoryContents.Singleton;
}
try
{
var entries = _assets.List(dirPath) ?? Array.Empty<string>();
return new AndroidAssetDirectoryContents(_assets, dirPath, entries);
}
catch
{
return NotFoundDirectoryContents.Singleton;
}
}
public IChangeToken Watch(string filter) => NullChangeToken.Singleton;
private static string Combine(string root, string subpath)
{
var cleanSub = NormalizePath(subpath).TrimStart('/');
if (string.IsNullOrEmpty(root)) return cleanSub;
if (string.IsNullOrEmpty(cleanSub)) return root;
return $"{root}/{cleanSub}";
}
private static string NormalizePath(string path) => (path ?? string.Empty).Replace('\\', '/');
private sealed class AndroidAssetFileInfo : IFileInfo
{
private readonly AssetManager _assets;
private readonly string _assetPath;
public AndroidAssetFileInfo(AssetManager assets, string assetPath, string name, bool isDirectory)
{
_assets = assets;
_assetPath = assetPath;
Name = name;
IsDirectory = isDirectory;
}
public bool Exists => true;
public long Length => -1;
public string? PhysicalPath => null;
public string Name { get; }
public DateTimeOffset LastModified => DateTimeOffset.MinValue;
public bool IsDirectory { get; }
public Stream CreateReadStream() => _assets.Open(_assetPath);
}
private sealed class AndroidAssetDirectoryContents : IDirectoryContents
{
private readonly AssetManager _assets;
private readonly string _dirPath;
private readonly string[] _entries;
public AndroidAssetDirectoryContents(AssetManager assets, string dirPath, string[] entries)
{
_assets = assets;
_dirPath = dirPath;
_entries = entries;
}
public bool Exists => true;
public IEnumerator<IFileInfo> GetEnumerator()
{
foreach (var entry in _entries)
{
if (string.IsNullOrEmpty(entry)) continue;
var childPath = $"{_dirPath}/{entry}";
var childList = Array.Empty<string>();
var isDir = false;
try
{
childList = _assets.List(childPath) ?? Array.Empty<string>();
isDir = childList.Length > 0;
}
catch
{
}
yield return new AndroidAssetFileInfo(_assets, childPath, entry, isDir);
}
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@drawable/appicon" android:roundIcon="@drawable/appicon_round" android:supportsRtl="true" android:networkSecurityConfig="@xml/network_security_config"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,18 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace Hua.Todo.Maui;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnCreate(Bundle? savedInstanceState)
{
base.OnCreate(savedInstanceState);
#if DEBUG
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true);
#endif
}
}
@@ -0,0 +1,15 @@
using Android.App;
using Android.Runtime;
namespace Hua.Todo.Maui;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,464 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Hua.Todo.Application.Interfaces;
using Hua.Todo.Application.Models;
using Hua.Todo.Maui.Models;
namespace Hua.Todo.Maui.Services;
public sealed class MobileEmbeddedWebServerService : IEmbeddedWebServerService
{
private readonly AppSettings _appSettings;
private readonly IServiceProvider _services;
private TcpListener? _listener;
private CancellationTokenSource? _cts;
private Task? _acceptLoop;
public bool IsRunning => _listener != null;
public string BaseUrl => $"http://localhost:{_appSettings.WebServer.Port}";
public MobileEmbeddedWebServerService(AppSettings appSettings, IServiceProvider services)
{
_appSettings = appSettings;
_services = services;
}
public Task StartAsync()
{
if (_listener != null) return Task.CompletedTask;
_cts = new CancellationTokenSource();
_listener = new TcpListener(IPAddress.Loopback, _appSettings.WebServer.Port);
_listener.Start();
_acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
return Task.CompletedTask;
}
public async Task StopAsync()
{
if (_listener == null) return;
try
{
_cts?.Cancel();
}
catch
{
}
try
{
_listener.Stop();
}
catch
{
}
try
{
if (_acceptLoop != null) await _acceptLoop;
}
catch
{
}
_listener = null;
_acceptLoop = null;
_cts?.Dispose();
_cts = null;
}
private async Task AcceptLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested && _listener != null)
{
TcpClient? client = null;
try
{
client = await _listener.AcceptTcpClientAsync(token);
_ = Task.Run(() => HandleClientAsync(client, token), token);
}
catch
{
client?.Dispose();
}
}
}
private async Task HandleClientAsync(TcpClient client, CancellationToken token)
{
using var _ = client;
using var stream = client.GetStream();
HttpRequestData request;
try
{
request = await ReadRequestAsync(stream, token);
}
catch
{
return;
}
if (request.Path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
await HandleApiAsync(stream, request, token);
return;
}
await HandleStaticAsync(stream, request, token);
}
private async Task HandleApiAsync(Stream stream, HttpRequestData request, CancellationToken token)
{
try
{
var path = request.Path;
if (!path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length < 2)
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
var serviceName = segments[1];
if (!serviceName.Equals("task", StringComparison.OrdinalIgnoreCase))
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
using var scope = _services.CreateScope();
var taskService = scope.ServiceProvider.GetRequiredService<ITaskService>();
object? result = null;
var methodName = string.Empty;
var tail = segments.Skip(2).ToArray();
if (request.Method == "GET" && tail.Length == 0)
{
methodName = nameof(ITaskService.GetAllTasksAsync);
result = await taskService.GetAllTasksAsync();
}
else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("active", StringComparison.OrdinalIgnoreCase))
{
methodName = nameof(ITaskService.GetActiveTasksAsync);
result = await taskService.GetActiveTasksAsync();
}
else if (request.Method == "GET" && tail.Length == 1 && tail[0].Equals("completed", StringComparison.OrdinalIgnoreCase))
{
methodName = nameof(ITaskService.GetCompletedTasksAsync);
result = await taskService.GetCompletedTasksAsync();
}
else if (request.Method == "GET" && tail.Length == 1 && int.TryParse(tail[0], out var getId))
{
methodName = nameof(ITaskService.GetTaskByIdAsync);
result = await taskService.GetTaskByIdAsync(getId);
}
else if (request.Method == "GET" && tail.Length == 2 && tail[1].Equals("subtasks", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var parentId))
{
methodName = nameof(ITaskService.GetSubTasksAsync);
result = await taskService.GetSubTasksAsync(parentId);
}
else if (request.Method == "POST" && tail.Length == 0)
{
methodName = nameof(ITaskService.CreateTaskAsync);
var dto = DeserializeBody<CreateTaskDto>(request.Body);
result = await taskService.CreateTaskAsync(dto);
}
else if (request.Method == "PUT" && tail.Length == 0)
{
methodName = nameof(ITaskService.UpdateTaskAsync);
var dto = DeserializeBody<UpdateTaskDto>(request.Body);
result = await taskService.UpdateTaskAsync(dto);
}
else if (request.Method == "PATCH" && tail.Length == 2 && tail[1].Equals("toggle", StringComparison.OrdinalIgnoreCase) && int.TryParse(tail[0], out var toggleId))
{
methodName = nameof(ITaskService.ToggleCompleteAsync);
result = await taskService.ToggleCompleteAsync(toggleId);
}
else if (request.Method == "DELETE" && tail.Length == 1 && int.TryParse(tail[0], out var deleteId))
{
methodName = nameof(ITaskService.DeleteTaskAsync);
await taskService.DeleteTaskAsync(deleteId);
result = null;
}
else
{
await WriteJsonAsync(stream, 404, new { Success = false, Data = (object?)null, Message = "请求不存在", Errors = new[] { "Not Found" } }, token);
return;
}
var response = CreateApiResponse(result, null, methodName);
await WriteJsonAsync(stream, response.StatusCode, response.Payload, token);
}
catch (Exception ex)
{
var response = CreateApiResponse(null, ex, string.Empty);
await WriteJsonAsync(stream, response.StatusCode, response.Payload, token);
}
}
private async Task HandleStaticAsync(Stream stream, HttpRequestData request, CancellationToken token)
{
var path = request.Path;
if (string.IsNullOrEmpty(path) || path == "/") path = "/index.html";
if (path.StartsWith("/api", StringComparison.OrdinalIgnoreCase))
{
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
return;
}
var assetPath = $"wwwroot{path}";
if (path.Contains("..", StringComparison.Ordinal))
{
await WriteTextAsync(stream, 400, "Bad Request", "text/plain; charset=utf-8", token);
return;
}
if (!TryOpenAsset(assetPath, out var assetStream))
{
if (!Path.HasExtension(path))
{
assetPath = "wwwroot/index.html";
if (TryOpenAsset(assetPath, out assetStream))
{
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, "text/html; charset=utf-8", token);
}
return;
}
}
await WriteTextAsync(stream, 404, "Not Found", "text/plain; charset=utf-8", token);
return;
}
await using (assetStream)
{
await WriteStreamAsync(stream, 200, assetStream, GetContentType(path), token);
}
}
private static bool TryOpenAsset(string assetPath, out Stream stream)
{
try
{
stream = global::Android.App.Application.Context.Assets.Open(assetPath);
return true;
}
catch
{
stream = Stream.Null;
return false;
}
}
private static string GetContentType(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".html" => "text/html; charset=utf-8",
".js" => "text/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".svg" => "image/svg+xml",
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".ico" => "image/x-icon",
".json" => "application/json; charset=utf-8",
".map" => "application/json; charset=utf-8",
".txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream"
};
}
private static (int StatusCode, object Payload) CreateApiResponse(object? result, Exception? exception, string methodName)
{
var successMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取成功",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建成功",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新成功",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除成功",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作成功",
_ => "操作成功"
};
var failureMessage = methodName switch
{
_ when methodName.StartsWith("Get", StringComparison.OrdinalIgnoreCase) => "获取失败",
_ when methodName.StartsWith("Create", StringComparison.OrdinalIgnoreCase) => "创建失败",
_ when methodName.StartsWith("Update", StringComparison.OrdinalIgnoreCase) => "更新失败",
_ when methodName.StartsWith("Delete", StringComparison.OrdinalIgnoreCase) => "删除失败",
_ when methodName.StartsWith("Toggle", StringComparison.OrdinalIgnoreCase) => "操作失败",
_ => "操作失败"
};
List<string>? errors = null;
if (exception != null)
{
errors = new List<string> { exception.Message };
}
var payload = new
{
Success = exception == null,
Data = exception == null ? result : null,
Message = exception == null ? successMessage : failureMessage,
Errors = errors
};
var statusCode = exception switch
{
KeyNotFoundException => 404,
ArgumentException => 400,
_ => 200
};
return (statusCode, payload);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
};
private static T DeserializeBody<T>(string body) where T : new()
{
if (string.IsNullOrWhiteSpace(body)) return new T();
return JsonSerializer.Deserialize<T>(body, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() }
}) ?? new T();
}
private static async Task WriteJsonAsync(Stream stream, int statusCode, object payload, CancellationToken token)
{
var json = JsonSerializer.Serialize(payload, JsonOptions);
await WriteTextAsync(stream, statusCode, json, "application/json; charset=utf-8", token);
}
private static async Task WriteTextAsync(Stream stream, int statusCode, string body, string contentType, CancellationToken token)
{
var bytes = Encoding.UTF8.GetBytes(body);
var header =
$"HTTP/1.1 {statusCode} {GetReasonPhrase(statusCode)}\r\n" +
$"Content-Type: {contentType}\r\n" +
$"Content-Length: {bytes.Length}\r\n" +
$"Connection: close\r\n" +
$"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, token);
await stream.WriteAsync(bytes, token);
}
private static async Task WriteStreamAsync(Stream stream, int statusCode, Stream bodyStream, string contentType, CancellationToken token)
{
await using var ms = new MemoryStream();
await bodyStream.CopyToAsync(ms, token);
var bodyBytes = ms.ToArray();
var header =
$"HTTP/1.1 {statusCode} {GetReasonPhrase(statusCode)}\r\n" +
$"Content-Type: {contentType}\r\n" +
$"Content-Length: {bodyBytes.Length}\r\n" +
$"Connection: close\r\n" +
$"\r\n";
var headerBytes = Encoding.ASCII.GetBytes(header);
await stream.WriteAsync(headerBytes, token);
await stream.WriteAsync(bodyBytes, token);
}
private static async Task<HttpRequestData> ReadRequestAsync(NetworkStream stream, CancellationToken token)
{
using var reader = new StreamReader(stream, Encoding.ASCII, detectEncodingFromByteOrderMarks: false, bufferSize: 8192, leaveOpen: true);
var requestLine = await reader.ReadLineAsync(token);
if (string.IsNullOrWhiteSpace(requestLine)) throw new InvalidOperationException("empty request line");
var parts = requestLine.Split(' ');
if (parts.Length < 2) throw new InvalidOperationException("invalid request line");
var method = parts[0].Trim().ToUpperInvariant();
var target = parts[1].Trim();
string path;
if (Uri.TryCreate(target, UriKind.Absolute, out var absoluteUri) &&
(absoluteUri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ||
absoluteUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)))
{
path = absoluteUri.AbsolutePath;
}
else if (target.StartsWith("//", StringComparison.Ordinal) &&
Uri.TryCreate("http:" + target, UriKind.Absolute, out var authorityUri))
{
path = authorityUri.AbsolutePath;
}
else
{
path = target;
var queryIndex = target.IndexOf('?', StringComparison.Ordinal);
if (queryIndex >= 0) path = target.Substring(0, queryIndex);
}
if (string.IsNullOrEmpty(path)) path = "/";
if (!path.StartsWith("/", StringComparison.Ordinal)) path = "/" + path;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? line;
while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync(token)))
{
var idx = line.IndexOf(':');
if (idx <= 0) continue;
var name = line.Substring(0, idx).Trim();
var value = line.Substring(idx + 1).Trim();
headers[name] = value;
}
var body = string.Empty;
if (headers.TryGetValue("Content-Length", out var contentLengthStr) && int.TryParse(contentLengthStr, out var contentLength) && contentLength > 0)
{
var buffer = new char[contentLength];
var read = 0;
while (read < contentLength)
{
var n = await reader.ReadAsync(buffer, read, contentLength - read);
if (n <= 0) break;
read += n;
}
body = new string(buffer, 0, read);
}
return new HttpRequestData(method, path, body);
}
private static string GetReasonPhrase(int statusCode) => statusCode switch
{
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK"
};
private readonly record struct HttpRequestData(string Method, string Path, string Body);
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#512BD4"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,56 L40,72 L84,28 L92,36 L40,88 L16,64 z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#512BD4"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,56 L40,72 L84,28 L92,36 L40,88 L16,64 z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,9 @@
using Foundation;
namespace Hua.Todo.Maui;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
<!-- Accessibility entitlement for global hotkey support -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.lifestyle</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
@@ -0,0 +1,15 @@
using ObjCRuntime;
using UIKit;
namespace Hua.Todo.Maui;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="Hua.Todo.Maui.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:Hua.Todo.Maui.WinUI">
</maui:MauiWinUIApplication>
@@ -0,0 +1,24 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Hua.Todo.Maui.WinUI;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="C9D66914-AC5B-46D6-BE4B-214C893F6CB9" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient" />
</Capabilities>
</Package>
@@ -0,0 +1,129 @@
using System.Runtime.InteropServices;
namespace Hua.Todo.Maui.Platforms.Windows
{
public class WindowsKeyboardHandler : IDisposable
{
private KeyboardHook _keyboardHook;
private bool _isDisposed;
public event EventHandler? EscKeyPressed;
public WindowsKeyboardHandler()
{
_keyboardHook = new KeyboardHook();
_keyboardHook.KeyPressed += OnKeyPressed;
}
public void Start()
{
_keyboardHook.Hook();
}
private void OnKeyPressed(object? sender, KeyPressedEventArgs e)
{
if (e.Key == 0x1B && !e.IsKeyDown)
{
EscKeyPressed?.Invoke(this, EventArgs.Empty);
}
}
public void Dispose()
{
if (!_isDisposed)
{
_keyboardHook.Unhook();
_keyboardHook.KeyPressed -= OnKeyPressed;
_isDisposed = true;
}
}
}
internal class KeyboardHook : IDisposable
{
private const int WH_KEYBOARD_LL = 13;
private const int WM_KEYDOWN = 0x0100;
private const int WM_KEYUP = 0x0101;
private const int WM_SYSKEYDOWN = 0x0104;
private const int WM_SYSKEYUP = 0x0105;
private IntPtr _hookID = IntPtr.Zero;
private LowLevelKeyboardProc _proc;
private bool _isDisposed;
public event EventHandler<KeyPressedEventArgs>? KeyPressed;
public KeyboardHook()
{
_proc = HookCallback;
}
public void Hook()
{
_hookID = SetWindowsHookEx(WH_KEYBOARD_LL, _proc, GetModuleHandle("user32"), 0);
}
public void Unhook()
{
if (_hookID != IntPtr.Zero)
{
UnhookWindowsHookEx(_hookID);
_hookID = IntPtr.Zero;
}
}
private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
int vkCode = Marshal.ReadInt32(lParam);
bool isKeyDown = wParam == (IntPtr)WM_KEYDOWN || wParam == (IntPtr)WM_SYSKEYDOWN;
bool isKeyUp = wParam == (IntPtr)WM_KEYUP || wParam == (IntPtr)WM_SYSKEYUP;
if (isKeyDown || isKeyUp)
{
var args = new KeyPressedEventArgs(vkCode, isKeyDown);
KeyPressed?.Invoke(this, args);
}
}
return CallNextHookEx(_hookID, nCode, wParam, lParam);
}
public void Dispose()
{
if (!_isDisposed)
{
Unhook();
_isDisposed = true;
}
}
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern IntPtr GetModuleHandle(string lpModuleName);
}
internal delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
internal class KeyPressedEventArgs : EventArgs
{
public int Key { get; }
public bool IsKeyDown { get; }
public KeyPressedEventArgs(int key, bool isKeyDown)
{
Key = key;
IsKeyDown = isKeyDown;
}
}
}
@@ -0,0 +1,68 @@
#if WINDOWS
using System.Runtime.InteropServices;
using Microsoft.Maui.Controls;
using Microsoft.UI.Windowing;
using WinRT.Interop;
namespace Hua.Todo.Maui.Platforms.Windows
{
public class WindowsWindowService
{
private const int SW_HIDE = 0;
private const int SW_SHOW = 5;
private const int SW_RESTORE = 9;
[DllImport("user32.dll", SetLastError = true)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
public void HideWindow(Window window)
{
if (window == null) return;
var platformWindow = window.Handler?.PlatformView;
if (platformWindow == null) return;
var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow;
var hWnd = WindowNative.GetWindowHandle(nativeWindow);
if (hWnd == IntPtr.Zero) return;
ShowWindow(hWnd, SW_HIDE);
}
public void RestoreWindow(Window window)
{
if (window == null) return;
var platformWindow = window.Handler?.PlatformView;
if (platformWindow == null) return;
var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow;
var hWnd = WindowNative.GetWindowHandle(nativeWindow);
if (hWnd == IntPtr.Zero) return;
ShowWindow(hWnd, SW_SHOW);
ShowWindow(hWnd, SW_RESTORE);
}
public void MinimizeWindow(Window window)
{
if (window == null) return;
var platformWindow = window.Handler?.PlatformView;
if (platformWindow == null) return;
var nativeWindow = (Microsoft.UI.Xaml.Window)platformWindow;
var appWindow = nativeWindow.AppWindow;
if (appWindow != null)
{
var overlappedPresenter = appWindow.Presenter as OverlappedPresenter;
if (overlappedPresenter != null)
{
overlappedPresenter.Minimize();
}
}
}
}
}
#endif
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Hua.Todo.Maui.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
@@ -0,0 +1,9 @@
using Foundation;
namespace Hua.Todo.Maui;
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
@@ -0,0 +1,15 @@
using ObjCRuntime;
using UIKit;
namespace Hua.Todo.Maui;
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>
+149
View File
@@ -0,0 +1,149 @@
# Hua.Todo MAUI 跨平台快捷键功能
## 功能概述
本项目实现了基于 MAUI + WebView 架构的跨平台代办管理应用,支持全局快捷键快速唤醒功能。
## 支持的平台
- **Windows**: 完整支持,使用 Windows API 实现全局快捷键
- **macOS**: 完整支持,使用 AppKit API 实现全局快捷键
- **Android**: 基础支持,通过通知快捷方式实现
- **iOS**: 基础支持,通过通知快捷方式实现
## 核心功能
### 1. 全局快捷键
- **Windows 默认快捷键**: `Alt + X`
- **macOS 默认快捷键**: `Cmd + Option + X`
- **移动端**: 通过通知快捷方式实现
### 2. 快捷键设置
- 用户可以自定义快捷键组合
- 支持多种修饰键组合(Alt、Control、Shift 等)
- 支持禁用/启用快捷键功能
- 设置持久化保存
### 3. WebView 集成
- 主界面嵌入 WebView,显示 Hua.Todo Web 应用
- 快捷键按下时自动激活窗口并聚焦 WebView
- 支持与本地 API 服务通信
## 项目结构
```
Hua.Todo.Maui/
├── Models/
│ └── HotKeyConfig.cs # 快捷键配置模型
├── Services/
│ ├── IGlobalHotKeyService.cs # 快捷键服务接口
│ ├── HotKeySettingsService.cs # 设置存储服务
│ ├── GlobalHotKeyServiceFactory.cs # 平台工厂
│ └── Platforms/
│ ├── WindowsGlobalHotKeyService.cs # Windows 实现
│ ├── MacGlobalHotKeyService.cs # macOS 实现
│ └── MobileGlobalHotKeyService.cs # 移动端实现
├── Views/
│ ├── MainPage.xaml # 主页面(WebView
│ ├── MainPage.xaml.cs
│ ├── HotKeySettingsPage.xaml # 快捷键设置页面
│ └── HotKeySettingsPage.xaml.cs
├── App.xaml.cs # 应用入口
└── MauiProgram.cs # MAUI 配置
```
## 使用方法
### 1. 运行项目
#### Windows
```bash
cd Hua.Todo.Maui
dotnet build -f net10.0-windows10.0.19041.0
dotnet run -f net10.0-windows10.0.19041.0
```
#### macOS
```bash
cd Hua.Todo.Maui
dotnet build -f net10.0-maccatalyst
dotnet run -f net10.0-maccatalyst
```
#### Android
```bash
cd Hua.Todo.Maui
dotnet build -f net10.0-android
dotnet run -f net10.0-android
```
### 2. 使用快捷键
1. 启动应用后,默认快捷键为 `Alt + X`Windows)或 `Cmd + Option + X`macOS
2. 按下快捷键,应用窗口会自动激活并聚焦
3. 在 WebView 中可以直接输入任务内容
### 3. 自定义快捷键
1. 点击主界面右上角的"设置"按钮
2. 在设置页面中:
- 切换"启用快捷键"开关
- 选择想要的修饰键组合
- 选择主键
- 点击"保存设置"按钮
3. 设置会立即生效
### 4. 恢复默认设置
在设置页面点击"恢复默认"按钮,快捷键将恢复为默认配置。
## 技术实现细节
### Windows 平台
- 使用 `RegisterHotKey``UnregisterHotKey` Windows API
- 支持全局快捷键监听
- 通过 `WindowNative` 获取窗口句柄
### macOS 平台
- 使用 `NSEvent.AddGlobalMonitorForEventsMatchingMask` 监听全局事件
- 支持 Command、Option、Control、Shift 等修饰键
- 需要配置 `com.apple.security.automation.apple-events` 权限
### 移动端
- Android: 使用 `ShortcutManagerCompat` 创建通知快捷方式
- iOS: 使用 `UIApplicationShortcutItem` 实现快捷操作
### 数据持久化
- 使用 `Microsoft.Maui.Storage.Preferences` 存储配置
- JSON 序列化保存快捷键配置
- 支持重置为默认配置
## 依赖项
### NuGet 包
- `Microsoft.Maui.Controls`
- `Microsoft.Extensions.DependencyInjection`
- `Microsoft.Extensions.Logging.Debug`
### 平台特定
- Windows: `Microsoft.Windows.SDK.BuildTools`
- macOS: `Microsoft.Maui.Controls.Compatibility`
## 注意事项
1. **macOS 权限**: 首次运行时需要在系统设置中授予辅助功能权限
2. **Windows UAC**: 某些情况下可能需要管理员权限
3. **移动端限制**: 移动端不支持真正的全局快捷键,使用通知快捷方式替代
4. **WebView**: 确保 Hua.Todo.Api 服务在 `http://localhost:5173` 运行
## 后续计划
- [ ] 添加 Linux 平台支持
- [ ] 实现云同步功能
- [ ] 添加更多快捷键功能
- [ ] 优化性能和响应速度
- [ ] 添加快捷键冲突检测
## 许可证
AGPL-3.0 License ([English](file:///d:/Proj/Hua.Todo/LICENSE) | [中文](file:///d:/Proj/Hua.Todo/LICENSE.zh-CN))
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

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