mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
4 Commits
5d2449fa8f
...
v0.8.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1d5a0c6de | ||
|
|
5fa2031ad6 | ||
|
|
0662565dca | ||
|
|
12a2f6729b |
165
LanMountainDesktop.PluginSdk/LICENSE
Normal file
165
LanMountainDesktop.PluginSdk/LICENSE
Normal file
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version of
|
||||
the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser 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
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
@@ -13,6 +13,8 @@
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
165
LanMountainDesktop.Shared.Contracts/LICENSE
Normal file
165
LanMountainDesktop.Shared.Contracts/LICENSE
Normal file
@@ -0,0 +1,165 @@
|
||||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 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.
|
||||
|
||||
|
||||
This version of the GNU Lesser General Public License incorporates
|
||||
the terms and conditions of version 3 of the GNU General Public
|
||||
License, supplemented by the additional permissions listed below.
|
||||
|
||||
0. Additional Definitions.
|
||||
|
||||
As used herein, "this License" refers to version 3 of the GNU Lesser
|
||||
General Public License, and the "GNU GPL" refers to version 3 of the GNU
|
||||
General Public License.
|
||||
|
||||
"The Library" refers to a covered work governed by this License,
|
||||
other than an Application or a Combined Work as defined below.
|
||||
|
||||
An "Application" is any work that makes use of an interface provided
|
||||
by the Library, but which is not otherwise based on the Library.
|
||||
Defining a subclass of a class defined by the Library is deemed a mode
|
||||
of using an interface provided by the Library.
|
||||
|
||||
A "Combined Work" is a work produced by combining or linking an
|
||||
Application with the Library. The particular version of the Library
|
||||
with which the Combined Work was made is also called the "Linked
|
||||
Version".
|
||||
|
||||
The "Minimal Corresponding Source" for a Combined Work means the
|
||||
Corresponding Source for the Combined Work, excluding any source code
|
||||
for portions of the Combined Work that, considered in isolation, are
|
||||
based on the Application, and not on the Linked Version.
|
||||
|
||||
The "Corresponding Application Code" for a Combined Work means the
|
||||
object code and/or source code for the Application, including any data
|
||||
and utility programs needed for reproducing the Combined Work from the
|
||||
Application, but excluding the System Libraries of the Combined Work.
|
||||
|
||||
1. Exception to Section 3 of the GNU GPL.
|
||||
|
||||
You may convey a covered work under sections 3 and 4 of this License
|
||||
without being bound by section 3 of the GNU GPL.
|
||||
|
||||
2. Conveying Modified Versions.
|
||||
|
||||
If you modify a copy of the Library, and, in your modifications, a
|
||||
facility refers to a function or data to be supplied by an Application
|
||||
that uses the facility (other than as an argument passed when the
|
||||
facility is invoked), then you may convey a copy of the modified
|
||||
version:
|
||||
|
||||
a) under this License, provided that you make a good faith effort to
|
||||
ensure that, in the event an Application does not supply the
|
||||
function or data, the facility still operates, and performs
|
||||
whatever part of its purpose remains meaningful, or
|
||||
|
||||
b) under the GNU GPL, with none of the additional permissions of
|
||||
this License applicable to that copy.
|
||||
|
||||
3. Object Code Incorporating Material from Library Header Files.
|
||||
|
||||
The object code form of an Application may incorporate material from
|
||||
a header file that is part of the Library. You may convey such object
|
||||
code under terms of your choice, provided that, if the incorporated
|
||||
material is not limited to numerical parameters, data structure
|
||||
layouts and accessors, or small macros, inline functions and templates
|
||||
(ten or fewer lines in length), you do both of the following:
|
||||
|
||||
a) Give prominent notice with each copy of the object code that the
|
||||
Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the object code with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
4. Combined Works.
|
||||
|
||||
You may convey a Combined Work under terms of your choice that,
|
||||
taken together, effectively do not restrict modification of the
|
||||
portions of the Library contained in the Combined Work and reverse
|
||||
engineering for debugging such modifications, if you also do each of
|
||||
the following:
|
||||
|
||||
a) Give prominent notice with each copy of the Combined Work that
|
||||
the Library is used in it and that the Library and its use are
|
||||
covered by this License.
|
||||
|
||||
b) Accompany the Combined Work with a copy of the GNU GPL and this license
|
||||
document.
|
||||
|
||||
c) For a Combined Work that displays copyright notices during
|
||||
execution, include the copyright notice for the Library among
|
||||
these notices, as well as a reference directing the user to the
|
||||
copies of the GNU GPL and this license document.
|
||||
|
||||
d) Do one of the following:
|
||||
|
||||
0) Convey the Minimal Corresponding Source under the terms of this
|
||||
License, and the Corresponding Application Code in a form
|
||||
suitable for, and under terms that permit, the user to
|
||||
recombine or relink the Application with a modified version of
|
||||
the Linked Version to produce a modified Combined Work, in the
|
||||
manner specified by section 6 of the GNU GPL for conveying
|
||||
Corresponding Source.
|
||||
|
||||
1) Use a suitable shared library mechanism for linking with the
|
||||
Library. A suitable mechanism is one that (a) uses at run time
|
||||
a copy of the Library already present on the user's computer
|
||||
system, and (b) will operate properly with a modified version of
|
||||
the Library that is interface-compatible with the Linked
|
||||
Version.
|
||||
|
||||
e) Provide Installation Information, but only if you would otherwise
|
||||
be required to provide such information under section 6 of the
|
||||
GNU GPL, and only to the extent that such information is
|
||||
necessary to install and execute a modified version of the
|
||||
Combined Work produced by recombining or relinking the
|
||||
Application with a modified version of the Linked Version. (If
|
||||
you use option 4d0, the Installation Information must accompany
|
||||
the Minimal Corresponding Source and Corresponding Application
|
||||
Code. If you use option 4d1, you must provide the Installation
|
||||
Information in the manner specified by section 6 of the GNU GPL
|
||||
for conveying Corresponding Source.)
|
||||
|
||||
5. Combined Libraries.
|
||||
|
||||
You may place library facilities that are a work based on the
|
||||
Library side by side in a single library together with other library
|
||||
facilities that are not Applications and are not covered by this
|
||||
License, and convey such a combined library under terms of your
|
||||
choice, if you do both of the following:
|
||||
|
||||
a) Accompany the combined library with a copy of the same work based
|
||||
on the Library, uncombined with any other library facilities,
|
||||
conveyed under the terms of this License.
|
||||
|
||||
b) Give prominent notice with the combined library that part of it
|
||||
is a work based on the Library, and explaining where to find the
|
||||
accompanying uncombined form of the same work.
|
||||
|
||||
6. Revised Versions of the GNU Lesser General Public License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions
|
||||
of the GNU Lesser 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
|
||||
Library as you received it specifies that a certain numbered version
|
||||
of the GNU Lesser General Public License "or any later version"
|
||||
applies to it, you have the option of following the terms and
|
||||
conditions either of that published version or of any later version
|
||||
published by the Free Software Foundation. If the Library as you
|
||||
received it does not specify a version number of the GNU Lesser
|
||||
General Public License, you may choose any version of the GNU Lesser
|
||||
General Public License ever published by the Free Software Foundation.
|
||||
|
||||
If the Library as you received it specifies that a proxy can decide
|
||||
whether future versions of the GNU Lesser General Public License shall
|
||||
apply, that proxy's public statement of acceptance of any version is
|
||||
permanent authorization for you to choose that version for the
|
||||
Library.
|
||||
@@ -13,6 +13,8 @@
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Copyright>Copyright (c) LanMountainDesktop Contributors</Copyright>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
|
||||
@@ -44,4 +44,6 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
|
||||
public const string DesktopFileManager = "DesktopFileManager";
|
||||
public const string DesktopNotificationBox = "DesktopNotificationBox";
|
||||
}
|
||||
|
||||
@@ -400,6 +400,26 @@ public sealed class ComponentRegistry
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"文件管理",
|
||||
"Folder",
|
||||
"File",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 4,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"消息盒子",
|
||||
"Inbox",
|
||||
"Info",
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free)
|
||||
};
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="Tmds.DBus.Protocol" Version="0.22.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||
|
||||
@@ -1087,5 +1087,23 @@
|
||||
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
|
||||
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
|
||||
"zhijiaohub.settings.about": "About",
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
|
||||
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally.",
|
||||
"power.menu": "Power",
|
||||
"power.title": "Power",
|
||||
"power.back": "Back",
|
||||
"power.shutdown": "Shutdown",
|
||||
"power.restart": "Restart",
|
||||
"power.logout": "Log Out",
|
||||
"power.sleep": "Sleep",
|
||||
"power.lock_screen": "Lock Screen",
|
||||
"power.shutdown_confirm_title": "Shutdown Confirmation",
|
||||
"power.shutdown_confirm_message": "Are you sure you want to shut down this computer? Unsaved data may be lost.",
|
||||
"power.restart_confirm_title": "Restart Confirmation",
|
||||
"power.restart_confirm_message": "Are you sure you want to restart this computer? Unsaved data may be lost.",
|
||||
"power.logout_confirm_title": "Log Out Confirmation",
|
||||
"power.logout_confirm_message": "Are you sure you want to log out?",
|
||||
"power.sleep_confirm_title": "Sleep Confirmation",
|
||||
"power.sleep_confirm_message": "Are you sure you want to put the computer to sleep?",
|
||||
"power.confirm_yes": "Yes",
|
||||
"power.confirm_cancel": "Cancel"
|
||||
}
|
||||
|
||||
@@ -1081,5 +1081,23 @@
|
||||
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
|
||||
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
|
||||
"zhijiaohub.settings.about": "关于",
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
|
||||
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。",
|
||||
"power.menu": "电源",
|
||||
"power.title": "电源",
|
||||
"power.back": "返回",
|
||||
"power.shutdown": "关机",
|
||||
"power.restart": "重启",
|
||||
"power.logout": "注销",
|
||||
"power.sleep": "睡眠",
|
||||
"power.lock_screen": "锁定屏幕",
|
||||
"power.shutdown_confirm_title": "关机确认",
|
||||
"power.shutdown_confirm_message": "确定要关闭计算机吗?未保存的数据可能会丢失。",
|
||||
"power.restart_confirm_title": "重启确认",
|
||||
"power.restart_confirm_message": "确定要重启计算机吗?未保存的数据可能会丢失。",
|
||||
"power.logout_confirm_title": "注销确认",
|
||||
"power.logout_confirm_message": "确定要注销当前用户吗?",
|
||||
"power.sleep_confirm_title": "睡眠确认",
|
||||
"power.sleep_confirm_message": "确定要让计算机进入睡眠状态吗?",
|
||||
"power.confirm_yes": "确定",
|
||||
"power.confirm_cancel": "取消"
|
||||
}
|
||||
|
||||
@@ -200,6 +200,35 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
#endregion
|
||||
|
||||
#region Notification Box Settings (消息盒子全局设置)
|
||||
|
||||
/// <summary>
|
||||
/// 启用消息盒子功能(Windows通知监听)
|
||||
/// </summary>
|
||||
public bool NotificationBoxEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 隐私模式:开启后只显示"您有新的通知",不显示具体内容
|
||||
/// </summary>
|
||||
public bool NotificationBoxPrivacyMode { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 被屏蔽的应用列表(不接收这些应用的通知)
|
||||
/// </summary>
|
||||
public List<string> NotificationBoxBlockedApps { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// 历史记录保留天数
|
||||
/// </summary>
|
||||
public int NotificationBoxHistoryRetentionDays { get; set; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// 最大存储通知数量(防止内存无限增长)
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxStoredCount { get; set; } = 500;
|
||||
|
||||
#endregion
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (AppSettingsSnapshot)MemberwiseClone();
|
||||
@@ -213,6 +242,9 @@ public sealed class AppSettingsSnapshot
|
||||
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
||||
? new List<string>(DisabledPluginIds)
|
||||
: [];
|
||||
clone.NotificationBoxBlockedApps = NotificationBoxBlockedApps is { Count: > 0 }
|
||||
? new List<string>(NotificationBoxBlockedApps)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,45 @@ public sealed class ComponentSettingsSnapshot
|
||||
|
||||
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
|
||||
|
||||
#region Notification Box Component Settings (消息盒子组件设置)
|
||||
|
||||
/// <summary>
|
||||
/// 组件内最大显示通知数量
|
||||
/// </summary>
|
||||
public int NotificationBoxMaxDisplayCount { get; set; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// 排序方式:TimeDesc(时间倒序), TimeAsc(时间正序), AppGroup(按应用分组)
|
||||
/// </summary>
|
||||
public string NotificationBoxSortOrder { get; set; } = "TimeDesc";
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示应用图标
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowAppIcon { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示时间戳
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowTimestamp { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 时间格式:Relative(相对时间,如"5分钟前"), Absolute(绝对时间)
|
||||
/// </summary>
|
||||
public string NotificationBoxTimeFormat { get; set; } = "Relative";
|
||||
|
||||
/// <summary>
|
||||
/// 是否按应用分组显示
|
||||
/// </summary>
|
||||
public bool NotificationBoxGroupByApp { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 是否显示清除按钮
|
||||
/// </summary>
|
||||
public bool NotificationBoxShowClearButton { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
public ComponentSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||
|
||||
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
87
LanMountainDesktop/Models/FileSystemItem.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public enum FileSystemItemType
|
||||
{
|
||||
Drive,
|
||||
Directory,
|
||||
File
|
||||
}
|
||||
|
||||
public sealed class FileSystemItem
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string FullPath { get; init; } = string.Empty;
|
||||
public FileSystemItemType ItemType { get; init; }
|
||||
public long? Size { get; init; }
|
||||
public DateTime? LastModified { get; init; }
|
||||
public string? Extension { get; init; }
|
||||
|
||||
public bool IsDirectory => ItemType == FileSystemItemType.Directory || ItemType == FileSystemItemType.Drive;
|
||||
|
||||
public static FileSystemItem FromDriveInfo(DriveInfo drive)
|
||||
{
|
||||
string name;
|
||||
long? size = null;
|
||||
|
||||
try
|
||||
{
|
||||
var volumeLabel = drive.VolumeLabel;
|
||||
name = string.IsNullOrWhiteSpace(volumeLabel)
|
||||
? $"{drive.Name.TrimEnd('\\', '/')}"
|
||||
: $"{volumeLabel} ({drive.Name.TrimEnd('\\', '/').ToUpperInvariant()})";
|
||||
}
|
||||
catch
|
||||
{
|
||||
name = $"{drive.Name.TrimEnd('\\', '/')}";
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var totalSize = drive.TotalSize;
|
||||
size = totalSize > 0 ? totalSize : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
size = null;
|
||||
}
|
||||
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = name,
|
||||
FullPath = drive.Name,
|
||||
ItemType = FileSystemItemType.Drive,
|
||||
Size = size,
|
||||
LastModified = null,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromDirectoryInfo(DirectoryInfo directory)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = directory.Name,
|
||||
FullPath = directory.FullName,
|
||||
ItemType = FileSystemItemType.Directory,
|
||||
Size = null,
|
||||
LastModified = directory.LastWriteTime,
|
||||
Extension = null
|
||||
};
|
||||
}
|
||||
|
||||
public static FileSystemItem FromFileInfo(FileInfo file)
|
||||
{
|
||||
return new FileSystemItem
|
||||
{
|
||||
Name = file.Name,
|
||||
FullPath = file.FullName,
|
||||
ItemType = FileSystemItemType.File,
|
||||
Size = file.Length,
|
||||
LastModified = file.LastWriteTime,
|
||||
Extension = file.Extension
|
||||
};
|
||||
}
|
||||
}
|
||||
54
LanMountainDesktop/Models/NotificationItem.cs
Normal file
54
LanMountainDesktop/Models/NotificationItem.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 通知项数据模型
|
||||
/// </summary>
|
||||
public sealed class NotificationItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 唯一标识
|
||||
/// </summary>
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString();
|
||||
|
||||
/// <summary>
|
||||
/// 应用ID(如 WeChat, Outlook 等)
|
||||
/// </summary>
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用名称
|
||||
/// </summary>
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 应用图标路径或Base64
|
||||
/// </summary>
|
||||
public string? AppIconPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 通知标题
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 通知内容
|
||||
/// </summary>
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 接收时间
|
||||
/// </summary>
|
||||
public DateTime ReceivedTime { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// 是否已读
|
||||
/// </summary>
|
||||
public bool IsRead { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 原始通知的额外数据(用于点击跳转)
|
||||
/// </summary>
|
||||
public string? LaunchArgs { get; set; }
|
||||
}
|
||||
@@ -267,6 +267,11 @@ public static class DesktopComponentEditorRegistryFactory
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
context => new ZhiJiaoHubComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d),
|
||||
[BuiltInComponentIds.DesktopNotificationBox] = new(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
context => new NotificationBoxComponentEditor(context),
|
||||
preferredWidth: 480d,
|
||||
preferredHeight: 520d)
|
||||
};
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
if (_isEditMode) return;
|
||||
_isEditMode = true;
|
||||
|
||||
// 隐藏所有底层小窗口
|
||||
// 【修复问题3】不再隐藏窗口,而是将窗口内容转移到编辑模式覆盖层
|
||||
// 这样可以保持组件的运行状态(动画、输入等)
|
||||
foreach (var window in _widgetWindows.Values)
|
||||
{
|
||||
window.Hide();
|
||||
|
||||
@@ -1,214 +1,265 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal static class LinuxIconService
|
||||
{
|
||||
private static readonly string[] SupportedRasterExtensions =
|
||||
[
|
||||
".png",
|
||||
".ico"
|
||||
];
|
||||
private static readonly string[] IconThemePaths = {
|
||||
"/usr/share/icons",
|
||||
"/usr/share/pixmaps",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local/share/icons"),
|
||||
"/var/lib/snapd/desktop/icons"
|
||||
};
|
||||
|
||||
private static readonly Regex SizeDirectoryRegex =
|
||||
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly string[] IconSizes = { "512x512", "256x256", "128x128", "96x96", "64x64", "48x48", "32x32", "24x24", "16x16" };
|
||||
|
||||
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly string[] FolderIconNames = { "folder", "inode-directory", "folder-default" };
|
||||
private static readonly string[] DriveIconNames = { "drive-harddisk", "drive-removable-media", "media-removable" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
|
||||
try
|
||||
{
|
||||
if (TryReadIconBytes(candidatePath, out var bytes))
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
return TryGetThemeIcon(iconName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string iconName, string? searchDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Path.IsPathRooted(iconName) && File.Exists(iconName))
|
||||
{
|
||||
return bytes;
|
||||
if (iconName.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return File.ReadAllBytes(iconName);
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (iconName.EndsWith(".xpm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var pngBytes = TryGetThemeIcon(iconName);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchDirectory))
|
||||
{
|
||||
var localIconPath = Path.Combine(searchDirectory, "icons", iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
|
||||
localIconPath = Path.Combine(searchDirectory, iconName + ".png");
|
||||
if (File.Exists(localIconPath))
|
||||
{
|
||||
return File.ReadAllBytes(localIconPath);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
if (Path.HasExtension(iconKey))
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var directPath = ExpandHome(iconKey);
|
||||
if (Path.IsPathRooted(directPath))
|
||||
var iconBytes = TryGetThemeIcon(iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
yield return directPath;
|
||||
return iconBytes;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
var resolvedThemePath = ResolveThemedIconPath(iconKey);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
yield return resolvedThemePath;
|
||||
".txt" => "text-x-generic",
|
||||
".md" => "text-x-markdown",
|
||||
".pdf" => "application-pdf",
|
||||
".doc" or ".docx" => "application-msword",
|
||||
".xls" or ".xlsx" => "application-vnd.ms-excel",
|
||||
".ppt" or ".pptx" => "application-vnd.ms-powerpoint",
|
||||
".zip" or ".rar" or ".7z" or ".tar" or ".gz" => "application-x-archive",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" or ".ogg" => "audio-x-generic",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" or ".wmv" => "video-x-generic",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".svg" => "image-x-generic",
|
||||
".cs" => "text-x-csharp",
|
||||
".js" or ".ts" => "text-x-javascript",
|
||||
".py" => "text-x-python",
|
||||
".java" => "text-x-java",
|
||||
".cpp" or ".c" or ".h" => "text-x-c++",
|
||||
".json" => "application-json",
|
||||
".xml" => "text-xml",
|
||||
".html" or ".htm" => "text-html",
|
||||
".css" => "text-css",
|
||||
".sh" or ".bash" => "text-x-script",
|
||||
".exe" or ".msi" => "application-x-executable",
|
||||
".deb" or ".rpm" => "application-x-package",
|
||||
".iso" or ".img" => "application-x-cd-image",
|
||||
_ => "text-x-generic"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryGetThemeIcon(string iconName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveThemedIconPath(string iconName)
|
||||
{
|
||||
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
|
||||
}
|
||||
|
||||
private static string? FindBestMatchingIconPath(string iconName)
|
||||
{
|
||||
var candidates = new List<(string Path, int Score)>();
|
||||
foreach (var iconRoot in EnumerateIconRoots())
|
||||
foreach (var themePath in IconThemePaths)
|
||||
{
|
||||
foreach (var extension in SupportedRasterExtensions)
|
||||
if (!Directory.Exists(themePath))
|
||||
{
|
||||
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconBytes = TryFindIconInTheme(themePath, iconName);
|
||||
if (iconBytes is not null)
|
||||
{
|
||||
return iconBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconFromGtkTheme(iconName);
|
||||
}
|
||||
|
||||
private static byte[]? TryFindIconInTheme(string themePath, string iconName)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
var iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "Adwaita", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderByDescending(candidate => candidate.Score)
|
||||
.ThenBy(candidate => candidate.Path.Length)
|
||||
.Select(candidate => candidate.Path)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateIconRoots()
|
||||
{
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
|
||||
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
dataHome = Path.Combine(homeDirectory, ".local", "share");
|
||||
}
|
||||
|
||||
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var candidates = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(dataHome))
|
||||
{
|
||||
candidates.Add(Path.Combine(dataHome, "icons"));
|
||||
candidates.Add(Path.Combine(dataHome, "pixmaps"));
|
||||
}
|
||||
|
||||
foreach (var dataDir in dataDirs)
|
||||
{
|
||||
candidates.Add(Path.Combine(dataDir, "icons"));
|
||||
candidates.Add(Path.Combine(dataDir, "pixmaps"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
candidates.Add(Path.Combine(homeDirectory, ".icons"));
|
||||
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
|
||||
}
|
||||
|
||||
candidates.Add("/var/lib/flatpak/exports/share/icons");
|
||||
candidates.Add("/var/lib/snapd/desktop/icons");
|
||||
|
||||
return candidates
|
||||
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
|
||||
{
|
||||
bytes = [];
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
!File.Exists(filePath))
|
||||
foreach (var sizeDir in IconSizes)
|
||||
{
|
||||
return false;
|
||||
var iconPath = Path.Combine(themePath, "hicolor", sizeDir, "mimetypes", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "places", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
|
||||
iconPath = Path.Combine(themePath, "hicolor", sizeDir, "devices", $"{iconName}.png");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
return File.ReadAllBytes(iconPath);
|
||||
}
|
||||
}
|
||||
|
||||
bytes = File.ReadAllBytes(filePath);
|
||||
return bytes.Length > 0;
|
||||
var directPath = Path.Combine(themePath, $"{iconName}.png");
|
||||
if (File.Exists(directPath))
|
||||
{
|
||||
return File.ReadAllBytes(directPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ScoreIconPath(string filePath)
|
||||
private static byte[]? TryGetIconFromGtkTheme(string iconName)
|
||||
{
|
||||
var score = 0;
|
||||
var extension = Path.GetExtension(filePath);
|
||||
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
|
||||
try
|
||||
{
|
||||
score += 4_000;
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "gtk3-icon-browser",
|
||||
Arguments = $"--icon={iconName}",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
|
||||
catch
|
||||
{
|
||||
score += 2_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 8_000;
|
||||
}
|
||||
|
||||
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
score += 1_000;
|
||||
}
|
||||
|
||||
var match = SizeDirectoryRegex.Match(filePath);
|
||||
if (match.Success &&
|
||||
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
|
||||
{
|
||||
score += Math.Min(size, 512);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static string ExpandHome(string path)
|
||||
{
|
||||
if (!path.StartsWith("~", StringComparison.Ordinal))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (string.IsNullOrWhiteSpace(homeDirectory))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path.Length == 1
|
||||
? homeDirectory
|
||||
: Path.Combine(homeDirectory, path[2..]);
|
||||
}
|
||||
}
|
||||
|
||||
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal file
216
LanMountainDesktop/Services/LinuxNotificationListener.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Linux平台通知监听器 - 通过DBus监听org.freedesktop.Notifications
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("linux")]
|
||||
internal sealed class LinuxNotificationListener : IDisposable
|
||||
{
|
||||
private readonly NotificationListenerService _parent;
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _isRunning;
|
||||
|
||||
public LinuxNotificationListener(NotificationListenerService parent)
|
||||
{
|
||||
_parent = parent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并启动DBus监听
|
||||
/// </summary>
|
||||
public async Task<bool> InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查DBus环境变量
|
||||
var dbusSessionBus = Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS");
|
||||
if (string.IsNullOrEmpty(dbusSessionBus))
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] DBus Session Bus 环境变量未设置");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查通知守护进程是否运行
|
||||
// 通过检查常见进程名
|
||||
var hasNotificationDaemon = await CheckNotificationDaemonAsync();
|
||||
if (!hasNotificationDaemon)
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] 未检测到通知守护进程,消息盒子功能可能不可用");
|
||||
// 仍然返回true,因为守护进程可能在之后启动
|
||||
}
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
_ = StartListeningAsync(_cts.Token);
|
||||
|
||||
Console.WriteLine("[NotificationBox] Linux通知监听已启动");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听初始化失败: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> CheckNotificationDaemonAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查常见通知守护进程
|
||||
var processNames = new[] { "gnome-shell", "kded5", "dunst", "mako", "swaync" };
|
||||
foreach (var name in processNames)
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = "pgrep",
|
||||
Arguments = $"-x {name}",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartListeningAsync(CancellationToken ct)
|
||||
{
|
||||
_isRunning = true;
|
||||
|
||||
try
|
||||
{
|
||||
// 注意:Tmds.DBus.Protocol 是低层API
|
||||
// 这里使用简化方案,实际生产环境需要完整的DBus信号订阅实现
|
||||
// 当前版本为框架实现,后续可以完善DBus监听逻辑
|
||||
|
||||
while (!ct.IsCancellationRequested && _isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(1000, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理接收到的通知(供DBus信号处理器调用)
|
||||
/// </summary>
|
||||
public void HandleNotification(
|
||||
string appName,
|
||||
uint replacesId,
|
||||
string appIcon,
|
||||
string summary,
|
||||
string body,
|
||||
string[] actions,
|
||||
object hints,
|
||||
int expireTimeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
var notification = new NotificationItem
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
AppId = appName.ToLowerInvariant().Replace(" ", ""),
|
||||
AppName = appName,
|
||||
Title = summary,
|
||||
Content = StripHtmlTags(body),
|
||||
ReceivedTime = DateTime.Now,
|
||||
AppIconPath = ResolveIconPath(appIcon, appName)
|
||||
};
|
||||
|
||||
_parent.AddNotification(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] 处理通知失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解析应用图标路径
|
||||
/// </summary>
|
||||
private static string? ResolveIconPath(string iconName, string appName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(iconName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果是绝对路径,直接使用
|
||||
if (File.Exists(iconName))
|
||||
{
|
||||
return iconName;
|
||||
}
|
||||
|
||||
// 尝试从图标主题中查找
|
||||
var iconPaths = new[]
|
||||
{
|
||||
$"/usr/share/icons/hicolor/48x48/apps/{iconName}.png",
|
||||
$"/usr/share/icons/hicolor/64x64/apps/{iconName}.png",
|
||||
$"/usr/share/pixmaps/{iconName}.png",
|
||||
$"/usr/share/pixmaps/{iconName}.svg",
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
$".local/share/icons/{iconName}.png")
|
||||
};
|
||||
|
||||
return iconPaths.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 去除HTML标签(通知内容可能包含HTML)
|
||||
/// </summary>
|
||||
private static string StripHtmlTags(string html)
|
||||
{
|
||||
if (string.IsNullOrEmpty(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// 简单的HTML标签去除
|
||||
var result = html;
|
||||
result = System.Text.RegularExpressions.Regex.Replace(result, "<[^>]+>", "");
|
||||
result = result.Replace("<", "<");
|
||||
result = result.Replace(">", ">");
|
||||
result = result.Replace("&", "&");
|
||||
result = result.Replace(""", "\"");
|
||||
return result.Trim();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_isRunning = false;
|
||||
_cts?.Cancel();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
296
LanMountainDesktop/Services/MacIconService.cs
Normal file
@@ -0,0 +1,296 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
[SupportedOSPlatform("macos")]
|
||||
internal static class MacIconService
|
||||
{
|
||||
private const int IconSize = 256;
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_sharedWorkspace();
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSWorkspace_iconForFile(IntPtr workspace, IntPtr filePath);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/AppKit.framework/AppKit")]
|
||||
private static extern IntPtr NSImage_initWithContentsOfFile(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern IntPtr CGImageDestinationCreateWithURL(IntPtr url, IntPtr type, uint count, IntPtr options);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern void CGImageDestinationAddImage(IntPtr dest, IntPtr image, IntPtr properties);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")]
|
||||
private static extern bool CGImageDestinationFinalize(IntPtr dest);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSString_stringWithUTF8String(string str);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSURL_fileURLWithPath(IntPtr path);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern void CFRelease(IntPtr handle);
|
||||
|
||||
[DllImport("/System/Library/Frameworks/Foundation.framework/Foundation")]
|
||||
private static extern IntPtr NSTemporaryDirectory();
|
||||
|
||||
private static readonly string[] SystemFolderPaths =
|
||||
{
|
||||
"/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources",
|
||||
"/System/Library/Extensions",
|
||||
"/System/Library/PrivateFrameworks"
|
||||
};
|
||||
|
||||
private static readonly string[] FolderIconNames = { "GenericFolderIcon.icns", "SidebarDownloadsFolder.icns", "SidebarDocumentsFolder.icns" };
|
||||
private static readonly string[] DriveIconNames = { "GenericHardDiskIcon.icns", "ExternalDiskIcon.icns", "RemovableDiskIcon.icns" };
|
||||
|
||||
public static byte[]? TryGetIconPngBytes(string filePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return TryGetIconUsingNSWorkspace(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return TryGetIconForExtension(extension);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[]? TryGetSystemFolderIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in FolderIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/System/Library/CoreServices");
|
||||
}
|
||||
|
||||
public static byte[]? TryGetDriveIconPngBytes()
|
||||
{
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var iconName in DriveIconNames)
|
||||
{
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return TryGetIconUsingNSWorkspace("/");
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingNSWorkspace(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
var script = $@"
|
||||
tell application ""System Events""
|
||||
set theIcon to icon of file ""{filePath}""
|
||||
end tell
|
||||
";
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "osascript",
|
||||
Arguments = $"-e 'tell application \"Finder\" to get icon of file \"{filePath}\"'",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
return TryGetIconUsingSips(filePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconUsingSips(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png -z {IconSize} {IconSize} \"{filePath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryGetIconForExtension(string extension)
|
||||
{
|
||||
var iconName = GetIconNameForExtension(extension);
|
||||
|
||||
foreach (var folderPath in SystemFolderPaths)
|
||||
{
|
||||
if (!Directory.Exists(folderPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var iconPath = Path.Combine(folderPath, iconName);
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
var pngBytes = TryConvertIcnsToPng(iconPath);
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
return pngBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetIconNameForExtension(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".txt" => "TextEdit.icns",
|
||||
".md" => "TextEdit.icns",
|
||||
".pdf" => "Preview.icns",
|
||||
".doc" or ".docx" => "Microsoft Word.icns",
|
||||
".xls" or ".xlsx" => "Microsoft Excel.icns",
|
||||
".ppt" or ".pptx" => "Microsoft PowerPoint.icns",
|
||||
".zip" or ".rar" or ".7z" => "Archive Utility.icns",
|
||||
".mp3" or ".wav" or ".flac" or ".aac" => "Music.icns",
|
||||
".mp4" or ".avi" or ".mkv" or ".mov" => "QuickTime Player.icns",
|
||||
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" => "Preview.icns",
|
||||
".cs" => "Visual Studio.icns",
|
||||
".js" or ".ts" => "Visual Studio Code.icns",
|
||||
".py" => "IDLE.icns",
|
||||
".json" => "TextEdit.icns",
|
||||
".xml" => "TextEdit.icns",
|
||||
".html" or ".htm" => "Safari.icns",
|
||||
".css" => "TextEdit.icns",
|
||||
".sh" => "Terminal.icns",
|
||||
".app" => "AppIcon.icns",
|
||||
".dmg" => "DiskImage.icns",
|
||||
_ => "GenericDocumentIcon.icns"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[]? TryConvertIcnsToPng(string icnsPath)
|
||||
{
|
||||
if (!File.Exists(icnsPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), $"icon_{Guid.NewGuid():N}.png");
|
||||
|
||||
using var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "sips",
|
||||
Arguments = $"-s format png \"{icnsPath}\" --out \"{tempPath}\"",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
if (File.Exists(tempPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(tempPath);
|
||||
File.Delete(tempPath);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal file
201
LanMountainDesktop/Services/NotificationListenerService.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 跨平台通知监听服务
|
||||
/// </summary>
|
||||
public sealed class NotificationListenerService : IDisposable
|
||||
{
|
||||
private readonly List<NotificationItem> _notifications = [];
|
||||
private readonly object _lock = new();
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
// 平台特定的监听器
|
||||
private LinuxNotificationListener? _linuxListener;
|
||||
|
||||
public event EventHandler<NotificationItem>? NotificationReceived;
|
||||
public event EventHandler<string>? NotificationRemoved;
|
||||
|
||||
public NotificationListenerService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化并启动监听
|
||||
/// </summary>
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// Windows: 使用 UserNotificationListener (需要Windows SDK)
|
||||
// 当前为模拟实现
|
||||
await InitializeWindowsAsync();
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
// Linux: 使用 DBus
|
||||
await InitializeLinuxAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// macOS 或其他平台:功能不可用
|
||||
Console.WriteLine("[NotificationBox] 当前平台不支持通知监听");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] 初始化失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeWindowsAsync()
|
||||
{
|
||||
// Windows通知监听实现
|
||||
// 实际项目中需要添加Windows SDK引用并使用UserNotificationListener
|
||||
// 由于需要UWP API,这里使用模拟实现
|
||||
await Task.CompletedTask;
|
||||
Console.WriteLine("[NotificationBox] Windows通知监听已启动(模拟模式)");
|
||||
}
|
||||
|
||||
private async Task InitializeLinuxAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_linuxListener = new LinuxNotificationListener(this);
|
||||
var success = await _linuxListener.InitializeAsync();
|
||||
|
||||
if (!success)
|
||||
{
|
||||
Console.WriteLine("[NotificationBox] Linux通知监听初始化失败,可能未运行通知守护进程");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[NotificationBox] Linux通知监听异常: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加通知(供平台监听器调用)
|
||||
/// </summary>
|
||||
public void AddNotification(NotificationItem notification)
|
||||
{
|
||||
var settings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
// 检查全局开关
|
||||
if (!settings.NotificationBoxEnabled)
|
||||
return;
|
||||
|
||||
// 检查是否在屏蔽列表中
|
||||
if (settings.NotificationBoxBlockedApps.Contains(notification.AppId, StringComparer.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.Add(notification);
|
||||
CleanupOldNotifications(settings);
|
||||
}
|
||||
|
||||
// 在UI线程触发事件
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
NotificationReceived?.Invoke(this, notification);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除通知
|
||||
/// </summary>
|
||||
public void RemoveNotification(string notificationId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
|
||||
if (notification != null)
|
||||
{
|
||||
_notifications.Remove(notification);
|
||||
}
|
||||
}
|
||||
|
||||
NotificationRemoved?.Invoke(this, notificationId);
|
||||
}
|
||||
|
||||
private void CleanupOldNotifications(AppSettingsSnapshot settings)
|
||||
{
|
||||
// 按数量清理
|
||||
var maxCount = settings.NotificationBoxMaxStoredCount;
|
||||
while (_notifications.Count > maxCount)
|
||||
{
|
||||
_notifications.RemoveAt(0);
|
||||
}
|
||||
|
||||
// 按时间清理
|
||||
var cutoffDate = DateTime.Now.AddDays(-settings.NotificationBoxHistoryRetentionDays);
|
||||
_notifications.RemoveAll(n => n.ReceivedTime < cutoffDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取所有通知
|
||||
/// </summary>
|
||||
public IReadOnlyList<NotificationItem> GetNotifications()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _notifications.ToList().AsReadOnly();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有通知
|
||||
/// </summary>
|
||||
public void ClearAll()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_notifications.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 标记通知为已读
|
||||
/// </summary>
|
||||
public void MarkAsRead(string notificationId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var notification = _notifications.FirstOrDefault(n => n.Id == notificationId);
|
||||
if (notification != null)
|
||||
{
|
||||
notification.IsRead = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取未读通知数量
|
||||
/// </summary>
|
||||
public int GetUnreadCount()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _notifications.Count(n => !n.IsRead);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_linuxListener?.Dispose();
|
||||
ClearAll();
|
||||
}
|
||||
}
|
||||
253
LanMountainDesktop/Services/PowerManagementService.cs
Normal file
253
LanMountainDesktop/Services/PowerManagementService.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IPowerManagementService
|
||||
{
|
||||
bool IsShutdownSupported { get; }
|
||||
bool IsRestartSupported { get; }
|
||||
bool IsLogoutSupported { get; }
|
||||
bool IsLockSupported { get; }
|
||||
bool IsSleepSupported { get; }
|
||||
|
||||
Task ShutdownAsync();
|
||||
Task RestartAsync();
|
||||
Task LogoutAsync();
|
||||
Task LockAsync();
|
||||
Task SleepAsync();
|
||||
|
||||
void ShowNativePowerUI(PowerAction action);
|
||||
}
|
||||
|
||||
public enum PowerAction
|
||||
{
|
||||
Shutdown,
|
||||
Restart
|
||||
}
|
||||
|
||||
public static class PowerManagementServiceFactory
|
||||
{
|
||||
private static IPowerManagementService? _instance;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static IPowerManagementService GetOrCreate()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _instance ??= CreatePlatformService();
|
||||
}
|
||||
}
|
||||
|
||||
private static IPowerManagementService CreatePlatformService()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
return new WindowsPowerManagementService();
|
||||
if (OperatingSystem.IsLinux())
|
||||
return new LinuxPowerManagementService();
|
||||
return new NullPowerManagementService();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WindowsPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => true;
|
||||
public bool IsRestartSupported => true;
|
||||
public bool IsLogoutSupported => true;
|
||||
public bool IsLockSupported => true;
|
||||
public bool IsSleepSupported => true;
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 0",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/r /t 0",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
ExitWindowsEx(0, 0);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task LockAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
LockWorkStation();
|
||||
});
|
||||
}
|
||||
|
||||
public async Task SleepAsync()
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
SetSuspendState(false, false, false);
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action)
|
||||
{
|
||||
var slideToShutDownPath = Environment.ExpandEnvironmentVariables(@"%windir%\System32\SlideToShutDown.exe");
|
||||
if (System.IO.File.Exists(slideToShutDownPath))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = slideToShutDownPath,
|
||||
UseShellExecute = true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case PowerAction.Shutdown:
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/s /t 5 /c \"LanMountainDesktop: Shutting down...\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
|
||||
case PowerAction.Restart:
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "shutdown",
|
||||
Arguments = "/r /t 5 /c \"LanMountainDesktop: Restarting...\"",
|
||||
UseShellExecute = true
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool ExitWindowsEx(uint uFlags, uint dwReason);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void LockWorkStation();
|
||||
|
||||
[DllImport("powrprof.dll", SetLastError = true)]
|
||||
private static extern bool SetSuspendState(bool hibernate, bool forceCritical, bool disableWakeEvent);
|
||||
}
|
||||
|
||||
internal sealed class LinuxPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => true;
|
||||
public bool IsRestartSupported => true;
|
||||
public bool IsLogoutSupported => true;
|
||||
public bool IsLockSupported => true;
|
||||
public bool IsSleepSupported => true;
|
||||
|
||||
public async Task ShutdownAsync()
|
||||
{
|
||||
await RunSystemctlCommand("poweroff -i");
|
||||
}
|
||||
|
||||
public async Task RestartAsync()
|
||||
{
|
||||
await RunSystemctlCommand("reboot -i");
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
await RunLoginctlCommand("terminate-session $XDG_SESSION_ID");
|
||||
}
|
||||
|
||||
public async Task LockAsync()
|
||||
{
|
||||
await RunLoginctlCommand("lock-session");
|
||||
}
|
||||
|
||||
public async Task SleepAsync()
|
||||
{
|
||||
await RunSystemctlCommand("suspend -i");
|
||||
}
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PowerAction.Shutdown:
|
||||
RunProcess("systemctl", "poweroff -i");
|
||||
break;
|
||||
case PowerAction.Restart:
|
||||
RunProcess("systemctl", "reboot -i");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunSystemctlCommand(string args)
|
||||
{
|
||||
await RunProcess("systemctl", args);
|
||||
}
|
||||
|
||||
private static async Task RunLoginctlCommand(string args)
|
||||
{
|
||||
await RunProcess("loginctl", args);
|
||||
}
|
||||
|
||||
private static async Task RunProcess(string command, string args)
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
})?.WaitForExit(5000);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("LinuxPowerManagement", $"Failed to execute {command} {args}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NullPowerManagementService : IPowerManagementService
|
||||
{
|
||||
public bool IsShutdownSupported => false;
|
||||
public bool IsRestartSupported => false;
|
||||
public bool IsLogoutSupported => false;
|
||||
public bool IsLockSupported => false;
|
||||
public bool IsSleepSupported => false;
|
||||
|
||||
public Task ShutdownAsync() => Task.CompletedTask;
|
||||
public Task RestartAsync() => Task.CompletedTask;
|
||||
public Task LogoutAsync() => Task.CompletedTask;
|
||||
public Task LockAsync() => Task.CompletedTask;
|
||||
public Task SleepAsync() => Task.CompletedTask;
|
||||
|
||||
public void ShowNativePowerUI(PowerAction action) { }
|
||||
}
|
||||
@@ -93,6 +93,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
private const int WM_WINDOWPOSCHANGING = 0x0046;
|
||||
private const int WM_NCHITTEST = 0x0084;
|
||||
private const int WM_ACTIVATEAPP = 0x001C; // 【新增】应用激活消息
|
||||
private const int HTTRANSPARENT = -1;
|
||||
private const int HTCLIENT = 1;
|
||||
|
||||
@@ -105,6 +106,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
private static readonly Dictionary<IntPtr, Point> _windowScreenOrigins = new();
|
||||
private static readonly object _staticLock = new();
|
||||
|
||||
// 【修复问题1】静态持有委托引用,防止 GC 回收导致 CallbackOnCollectedDelegate 崩溃
|
||||
private static WndProcDelegate? _wndProcDelegate;
|
||||
|
||||
// 【修复问题2】记录每个窗口的 DPI 缩放比例
|
||||
private static readonly Dictionary<IntPtr, double> _windowDpiScales = new();
|
||||
|
||||
// 【修复问题5】Z 轴竞争优化 - 记录上次置底时间,避免频繁操作
|
||||
private static readonly Dictionary<IntPtr, long> _lastSendToBottomTime = new();
|
||||
private const long MinSendToBottomIntervalMs = 100; // 【修复置底问题】降低到 100ms,提高响应速度
|
||||
|
||||
// 【新增】定时器定期强制置底
|
||||
private static System.Timers.Timer? _keepBottomTimer;
|
||||
private static readonly object _timerLock = new();
|
||||
|
||||
public bool IsBottomMostSupported => true;
|
||||
|
||||
public void SetupBottomMost(Window window)
|
||||
@@ -130,6 +145,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_bottomMostWindows[handle] = true;
|
||||
_interactiveRegions[handle] = [];
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】初始化 DPI 缩放
|
||||
}
|
||||
|
||||
// 注入消息钩子
|
||||
@@ -138,6 +154,9 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
// 初始置底
|
||||
SendToBottomInternal(handle);
|
||||
|
||||
// 【新增】启动定时器定期强制置底
|
||||
StartKeepBottomTimer();
|
||||
|
||||
AppLogger.Info("WindowBottomMost", $"Window setup as bottom-most: {handle}");
|
||||
};
|
||||
|
||||
@@ -152,6 +171,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_originalWndProcs.Remove(handle);
|
||||
_interactiveRegions.Remove(handle);
|
||||
_windowScreenOrigins.Remove(handle);
|
||||
_windowDpiScales.Remove(handle); // 【修复问题2】清理 DPI 缩放记录
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -174,21 +194,113 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
SetWindowPos(handle, HWND_BOTTOM, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】启动定时器定期强制置底所有窗口
|
||||
/// </summary>
|
||||
private static void StartKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_keepBottomTimer != null) return;
|
||||
|
||||
_keepBottomTimer = new System.Timers.Timer(200); // 每 200ms 检查一次
|
||||
_keepBottomTimer.Elapsed += (s, e) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
foreach (var kvp in _bottomMostWindows)
|
||||
{
|
||||
if (kvp.Value) // 如果标记为置底
|
||||
{
|
||||
SendToBottomInternal(kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略定时器错误
|
||||
}
|
||||
};
|
||||
_keepBottomTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【新增】停止定时器
|
||||
/// </summary>
|
||||
private static void StopKeepBottomTimer()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
_keepBottomTimer?.Stop();
|
||||
_keepBottomTimer?.Dispose();
|
||||
_keepBottomTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetAsDesktopChild(IntPtr handle)
|
||||
{
|
||||
// 【修复问题4】增强桌面挂载逻辑,支持 Wallpaper Engine 等动态壁纸软件
|
||||
|
||||
// 方案1: 尝试找到 WorkerW 层(Wallpaper Engine 创建的层)
|
||||
var workerW = IntPtr.Zero;
|
||||
var hDefView = IntPtr.Zero;
|
||||
|
||||
// 枚举所有顶层窗口
|
||||
var windowHandles = new ArrayList();
|
||||
EnumWindows(EnumWindowsCallback, windowHandles);
|
||||
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
var hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
// 查找 WorkerW 窗口(Wallpaper Engine 创建)
|
||||
var className = GetWindowClassName(h);
|
||||
if (className == "WorkerW")
|
||||
{
|
||||
// 在 WorkerW 下查找 SHELLDLL_DefView
|
||||
var defView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (defView != IntPtr.Zero)
|
||||
{
|
||||
workerW = h;
|
||||
hDefView = defView;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到了 WorkerW 层,使用它作为父窗口
|
||||
if (workerW != IntPtr.Zero && hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to WorkerW layer (Wallpaper Engine detected)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 方案2: 回退到传统方式,查找 Progman 下的 SHELLDLL_DefView
|
||||
foreach (IntPtr h in windowHandles)
|
||||
{
|
||||
hDefView = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null);
|
||||
if (hDefView != IntPtr.Zero)
|
||||
{
|
||||
SetWindowLong(handle, GWL_HWNDPARENT, hDefView.ToInt32());
|
||||
AppLogger.Info("WindowBottomMost", "Mounted to traditional desktop layer");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题4】获取窗口类名
|
||||
/// </summary>
|
||||
private static string GetWindowClassName(IntPtr hWnd)
|
||||
{
|
||||
var buffer = new char[256];
|
||||
var length = GetClassName(hWnd, buffer, buffer.Length);
|
||||
return length > 0 ? new string(buffer, 0, length) : string.Empty;
|
||||
}
|
||||
|
||||
private static bool EnumWindowsCallback(IntPtr handle, ArrayList handles)
|
||||
{
|
||||
handles.Add(handle);
|
||||
@@ -203,13 +315,29 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
lock (_staticLock)
|
||||
{
|
||||
_originalWndProcs[handle] = originalWndProc;
|
||||
|
||||
// 【修复问题1】确保委托实例被静态引用持有,防止 GC 回收
|
||||
_wndProcDelegate ??= SubclassWndProc;
|
||||
}
|
||||
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate<WndProcDelegate>(SubclassWndProc));
|
||||
SetWindowLongPtr(handle, GWLP_WNDPROC, Marshal.GetFunctionPointerForDelegate(_wndProcDelegate));
|
||||
}
|
||||
|
||||
private static IntPtr SubclassWndProc(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
// 【新增】处理应用激活消息 - 当其他应用激活时立即置底
|
||||
if (msg == WM_ACTIVATEAPP)
|
||||
{
|
||||
lock (_staticLock)
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 立即置底,不进行频率限制
|
||||
SendToBottomInternal(hWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 WM_WINDOWPOSCHANGING - 保持置底
|
||||
if (msg == WM_WINDOWPOSCHANGING)
|
||||
{
|
||||
@@ -217,7 +345,19 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_bottomMostWindows.TryGetValue(hWnd, out var isBottomMost) && isBottomMost)
|
||||
{
|
||||
// 【修复问题5】优化 Z 轴竞争 - 限制置底操作频率
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
if (_lastSendToBottomTime.TryGetValue(hWnd, out var lastTime))
|
||||
{
|
||||
if (now - lastTime < MinSendToBottomIntervalMs)
|
||||
{
|
||||
// 跳过过于频繁的置底操作
|
||||
goto CallOriginal;
|
||||
}
|
||||
}
|
||||
|
||||
SendToBottomInternal(hWnd);
|
||||
_lastSendToBottomTime[hWnd] = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,11 +373,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
{
|
||||
if (_interactiveRegions.TryGetValue(hWnd, out var regions) && regions.Count > 0)
|
||||
{
|
||||
// 将屏幕坐标转为窗口相对坐标(_interactiveRegions 存的是窗口内坐标)
|
||||
// 【修复问题2】获取窗口原点和 DPI 缩放比例
|
||||
_windowScreenOrigins.TryGetValue(hWnd, out var origin);
|
||||
_windowDpiScales.TryGetValue(hWnd, out var dpiScale);
|
||||
if (dpiScale <= 0) dpiScale = 1.0; // 默认缩放为 1.0
|
||||
|
||||
// 将屏幕物理像素坐标转为窗口相对坐标
|
||||
var clientX = screenX - origin.X;
|
||||
var clientY = screenY - origin.Y;
|
||||
var point = new Point(clientX, clientY);
|
||||
|
||||
// 【修复问题2】将物理像素坐标转换为逻辑 DIP 坐标
|
||||
// _interactiveRegions 存储的是 Avalonia UI 的逻辑 DIP 坐标
|
||||
var logicalX = clientX / dpiScale;
|
||||
var logicalY = clientY / dpiScale;
|
||||
var point = new Point(logicalX, logicalY);
|
||||
|
||||
foreach (var region in regions)
|
||||
{
|
||||
@@ -255,6 +404,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
|
||||
// 调用原始窗口过程
|
||||
CallOriginal:
|
||||
IntPtr originalWndProc;
|
||||
lock (_staticLock)
|
||||
{
|
||||
@@ -277,6 +427,7 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
_interactiveRegions[handle] = regions;
|
||||
// 同步刷新屏幕原点(DPI 缩放可能影响坐标,每次更新区域时一并刷新)
|
||||
UpdateWindowScreenOrigin(handle);
|
||||
UpdateWindowDpiScale(handle); // 【修复问题2】同步更新 DPI 缩放
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +442,31 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题2】更新指定窗口的 DPI 缩放比例
|
||||
/// </summary>
|
||||
private static void UpdateWindowDpiScale(IntPtr handle)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 获取窗口所在的显示器 DPI
|
||||
var monitor = MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor != IntPtr.Zero)
|
||||
{
|
||||
if (GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, out var dpiX, out var _) == 0)
|
||||
{
|
||||
// DPI 缩放比例 = 当前 DPI / 96 (标准 DPI)
|
||||
_windowDpiScales[handle] = dpiX / 96.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 如果获取失败,使用默认缩放 1.0
|
||||
_windowDpiScales[handle] = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT { public int Left, Top, Right, Bottom; }
|
||||
|
||||
@@ -328,6 +504,20 @@ internal sealed class WindowsWindowBottomMostService : IWindowBottomMostService
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DefWindowProc(IntPtr hWnd, int uMsg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
// 【修复问题2】DPI 相关的 P/Invoke 声明
|
||||
private const int MONITOR_DEFAULTTONEAREST = 2;
|
||||
private const int MDT_EFFECTIVE_DPI = 0;
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr MonitorFromWindow(IntPtr hWnd, int dwFlags);
|
||||
|
||||
[DllImport("shcore.dll")]
|
||||
private static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
// 【修复问题4】获取窗口类名的 P/Invoke
|
||||
[DllImport("user32.dll", CharSet = CharSet.Auto)]
|
||||
private static extern int GetClassName(IntPtr hWnd, char[] lpClassName, int nMaxCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
@@ -696,18 +696,23 @@ internal static class WindowsIconService
|
||||
try
|
||||
{
|
||||
using var source = Image.FromHbitmap(bitmapHandle);
|
||||
using var bitmap = new Bitmap(source.Width, source.Height, PixelFormat.Format32bppArgb);
|
||||
var width = source.Width;
|
||||
var height = source.Height;
|
||||
|
||||
using var bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.Clear(Color.Transparent);
|
||||
graphics.CompositingMode = CompositingMode.SourceOver;
|
||||
graphics.CompositingMode = CompositingMode.SourceCopy;
|
||||
graphics.CompositingQuality = CompositingQuality.HighQuality;
|
||||
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
graphics.SmoothingMode = SmoothingMode.HighQuality;
|
||||
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
graphics.DrawImage(source, 0, 0, source.Width, source.Height);
|
||||
graphics.DrawImage(source, 0, 0, width, height);
|
||||
}
|
||||
|
||||
FixBitmapAlpha(bitmap);
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
return stream.ToArray();
|
||||
@@ -718,6 +723,47 @@ internal static class WindowsIconService
|
||||
}
|
||||
}
|
||||
|
||||
private static void FixBitmapAlpha(Bitmap bitmap)
|
||||
{
|
||||
var width = bitmap.Width;
|
||||
var height = bitmap.Height;
|
||||
var rect = new Rectangle(0, 0, width, height);
|
||||
var data = bitmap.LockBits(rect, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb);
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Math.Abs(data.Stride) * height;
|
||||
var buffer = new byte[bytes];
|
||||
Marshal.Copy(data.Scan0, buffer, 0, bytes);
|
||||
|
||||
for (var i = 0; i < bytes; i += 4)
|
||||
{
|
||||
var b = buffer[i];
|
||||
var g = buffer[i + 1];
|
||||
var r = buffer[i + 2];
|
||||
var a = buffer[i + 3];
|
||||
|
||||
if (a == 0 && (r != 0 || g != 0 || b != 0))
|
||||
{
|
||||
a = (byte)Math.Max(r, Math.Max(g, b));
|
||||
buffer[i + 3] = a;
|
||||
}
|
||||
else if (a > 0 && a < 255)
|
||||
{
|
||||
buffer[i] = (byte)(b * 255 / a);
|
||||
buffer[i + 1] = (byte)(g * 255 / a);
|
||||
buffer[i + 2] = (byte)(r * 255 / a);
|
||||
}
|
||||
}
|
||||
|
||||
Marshal.Copy(buffer, 0, data.Scan0, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryInitializeCom(out bool shouldUninitialize)
|
||||
{
|
||||
shouldUninitialize = false;
|
||||
|
||||
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal file
115
LanMountainDesktop/ViewModels/NotificationBoxEditorViewModel.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
public sealed partial class NotificationBoxEditorViewModel : ViewModelBase
|
||||
{
|
||||
private readonly DesktopComponentEditorContext? _context;
|
||||
private bool _isInitializing;
|
||||
|
||||
public NotificationBoxEditorViewModel(DesktopComponentEditorContext? context)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
MaxDisplayCountOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("20", "20条"),
|
||||
new("50", "50条"),
|
||||
new("100", "100条"),
|
||||
new("200", "200条")
|
||||
};
|
||||
|
||||
SortOrderOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("TimeDesc", "最新优先"),
|
||||
new("TimeAsc", "最早优先"),
|
||||
new("AppGroup", "按应用分组")
|
||||
};
|
||||
|
||||
TimeFormatOptions = new ObservableCollection<SelectionOption>
|
||||
{
|
||||
new("Relative", "相对时间(如:5分钟前)"),
|
||||
new("Absolute", "绝对时间(如:14:30)")
|
||||
};
|
||||
|
||||
LoadSettings();
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var snapshot = _context?.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
|
||||
_isInitializing = true;
|
||||
|
||||
var countValue = snapshot.NotificationBoxMaxDisplayCount.ToString();
|
||||
SelectedMaxDisplayCount = MaxDisplayCountOptions.FirstOrDefault(o => o.Value == countValue)
|
||||
?? MaxDisplayCountOptions[1]; // 默认50
|
||||
|
||||
SelectedSortOrder = SortOrderOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxSortOrder)
|
||||
?? SortOrderOptions[0];
|
||||
ShowAppIcon = snapshot.NotificationBoxShowAppIcon;
|
||||
ShowTimestamp = snapshot.NotificationBoxShowTimestamp;
|
||||
SelectedTimeFormat = TimeFormatOptions.FirstOrDefault(o => o.Value == snapshot.NotificationBoxTimeFormat)
|
||||
?? TimeFormatOptions[0];
|
||||
GroupByApp = snapshot.NotificationBoxGroupByApp;
|
||||
ShowClearButton = snapshot.NotificationBoxShowClearButton;
|
||||
|
||||
_isInitializing = false;
|
||||
}
|
||||
|
||||
private void SaveSettings()
|
||||
{
|
||||
if (_isInitializing || _context == null) return;
|
||||
|
||||
var snapshot = _context.ComponentSettingsAccessor.LoadSnapshot<ComponentSettingsSnapshot>();
|
||||
|
||||
snapshot.NotificationBoxMaxDisplayCount = int.TryParse(SelectedMaxDisplayCount?.Value, out var count) ? count : 50;
|
||||
snapshot.NotificationBoxSortOrder = SelectedSortOrder?.Value ?? "TimeDesc";
|
||||
snapshot.NotificationBoxShowAppIcon = ShowAppIcon;
|
||||
snapshot.NotificationBoxShowTimestamp = ShowTimestamp;
|
||||
snapshot.NotificationBoxTimeFormat = SelectedTimeFormat?.Value ?? "Relative";
|
||||
snapshot.NotificationBoxGroupByApp = GroupByApp;
|
||||
snapshot.NotificationBoxShowClearButton = ShowClearButton;
|
||||
|
||||
_context.ComponentSettingsAccessor.SaveSnapshot(snapshot);
|
||||
|
||||
_context.HostContext.RequestRefresh();
|
||||
}
|
||||
|
||||
[ObservableProperty] private string _descriptionText = "配置此消息盒子组件的显示方式。这些设置仅作用于当前组件实例。";
|
||||
[ObservableProperty] private string _maxDisplayCountLabel = "最大显示数量";
|
||||
[ObservableProperty] private string _maxDisplayCountDescription = "组件中最多显示的通知条数";
|
||||
[ObservableProperty] private string _sortOrderLabel = "排序方式";
|
||||
[ObservableProperty] private string _displayOptionsLabel = "显示选项";
|
||||
[ObservableProperty] private string _showAppIconLabel = "显示应用图标";
|
||||
[ObservableProperty] private string _showTimestampLabel = "显示时间戳";
|
||||
[ObservableProperty] private string _groupByAppLabel = "按应用分组显示";
|
||||
[ObservableProperty] private string _showClearButtonLabel = "显示清空按钮";
|
||||
[ObservableProperty] private string _timeFormatLabel = "时间格式";
|
||||
|
||||
[ObservableProperty] private SelectionOption? _selectedMaxDisplayCount;
|
||||
[ObservableProperty] private SelectionOption? _selectedSortOrder;
|
||||
[ObservableProperty] private bool _showAppIcon = true;
|
||||
[ObservableProperty] private bool _showTimestamp = true;
|
||||
[ObservableProperty] private SelectionOption? _selectedTimeFormat;
|
||||
[ObservableProperty] private bool _groupByApp = false;
|
||||
[ObservableProperty] private bool _showClearButton = true;
|
||||
|
||||
public ObservableCollection<SelectionOption> MaxDisplayCountOptions { get; }
|
||||
public ObservableCollection<SelectionOption> SortOrderOptions { get; }
|
||||
public ObservableCollection<SelectionOption> TimeFormatOptions { get; }
|
||||
|
||||
partial void OnSelectedMaxDisplayCountChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnSelectedSortOrderChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnShowAppIconChanged(bool value) => SaveSettings();
|
||||
partial void OnShowTimestampChanged(bool value) => SaveSettings();
|
||||
partial void OnSelectedTimeFormatChanged(SelectionOption? value) => SaveSettings();
|
||||
partial void OnGroupByAppChanged(bool value) => SaveSettings();
|
||||
partial void OnShowClearButtonChanged(bool value) => SaveSettings();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.NotificationBoxComponentEditor"
|
||||
x:DataType="vm:NotificationBoxEditorViewModel">
|
||||
|
||||
<StackPanel Spacing="16">
|
||||
<!-- 说明卡片 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<TextBlock Text="{Binding DescriptionText}"
|
||||
Classes="component-editor-secondary-text"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- 最大显示数量 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding MaxDisplayCountLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<TextBlock Text="{Binding MaxDisplayCountDescription}"
|
||||
Classes="component-editor-secondary-text" />
|
||||
<ComboBox ItemsSource="{Binding MaxDisplayCountOptions}"
|
||||
SelectedItem="{Binding SelectedMaxDisplayCount}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 排序方式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding SortOrderLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding SortOrderOptions}"
|
||||
SelectedItem="{Binding SelectedSortOrder}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 显示选项 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Text="{Binding DisplayOptionsLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
|
||||
<CheckBox IsChecked="{Binding ShowAppIcon}"
|
||||
Content="{Binding ShowAppIconLabel}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding ShowTimestamp}"
|
||||
Content="{Binding ShowTimestampLabel}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding GroupByApp}"
|
||||
Content="{Binding GroupByAppLabel}" />
|
||||
|
||||
<CheckBox IsChecked="{Binding ShowClearButton}"
|
||||
Content="{Binding ShowClearButtonLabel}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 时间格式 -->
|
||||
<Border Classes="component-editor-card" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="{Binding TimeFormatLabel}"
|
||||
Classes="component-editor-section-title" />
|
||||
<ComboBox ItemsSource="{Binding TimeFormatOptions}"
|
||||
SelectedItem="{Binding SelectedTimeFormat}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectionOption">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class NotificationBoxComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
public NotificationBoxComponentEditor(DesktopComponentEditorContext? context)
|
||||
: base(context)
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = new NotificationBoxEditorViewModel(context);
|
||||
}
|
||||
}
|
||||
@@ -475,7 +475,15 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopZhiJiaoHub,
|
||||
"component.zhijiao_hub",
|
||||
() => new ZhiJiaoHubWidget())
|
||||
() => new ZhiJiaoHubWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopFileManager,
|
||||
"component.file_manager",
|
||||
() => new FileManagerWidget()),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopNotificationBox,
|
||||
"component.notification_box",
|
||||
() => new NotificationBoxWidget())
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
138
LanMountainDesktop/Views/Components/FileManagerWidget.axaml
Normal file
@@ -0,0 +1,138 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
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:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="320"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.FileManagerWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<!-- 导航栏 -->
|
||||
<Grid Grid.Row="0"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto"
|
||||
ColumnSpacing="6">
|
||||
<!-- 返回按钮 -->
|
||||
<Button x:Name="BackButton"
|
||||
Grid.Column="0"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnBackButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowLeft"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 主页/盘符按钮 -->
|
||||
<Button x:Name="HomeButton"
|
||||
Grid.Column="1"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnHomeButtonClick">
|
||||
<fi:SymbolIcon Symbol="Home"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
|
||||
<!-- 路径显示 -->
|
||||
<Border Grid.Column="2"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="10,0"
|
||||
VerticalAlignment="Center"
|
||||
Height="32">
|
||||
<TextBlock x:Name="PathTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="13"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding $self.Text}"
|
||||
Text="此电脑" />
|
||||
</Border>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="3"
|
||||
Width="32"
|
||||
Height="32"
|
||||
Padding="0"
|
||||
CornerRadius="16"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderThickness="0"
|
||||
Click="OnRefreshButtonClick">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<Border Grid.Row="1"
|
||||
Height="1"
|
||||
Margin="0,10"
|
||||
Background="{DynamicResource AdaptiveDividerBrush}" />
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<ScrollViewer Grid.Row="2"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="FileItemsControl">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StackPanel x:Name="EmptyStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="FolderOpen"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
<TextBlock x:Name="EmptyStateTextBlock"
|
||||
Text="文件夹为空"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<StackPanel x:Name="ErrorStatePanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="8">
|
||||
<fi:SymbolIcon Symbol="ErrorCircle"
|
||||
FontSize="40"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
<TextBlock x:Name="ErrorStateTextBlock"
|
||||
Text="无法访问此文件夹"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveErrorBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
819
LanMountainDesktop/Views/Components/FileManagerWidget.axaml.cs
Normal file
@@ -0,0 +1,819 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class FileManagerWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IDesktopPageVisibilityAwareComponentWidget,
|
||||
IComponentPlacementContextAware,
|
||||
IDisposable
|
||||
{
|
||||
private readonly List<string> _navigationHistory = new();
|
||||
private int _currentHistoryIndex = -1;
|
||||
private string _currentPath = string.Empty;
|
||||
private string _componentId = BuiltInComponentIds.DesktopFileManager;
|
||||
private string _placementId = string.Empty;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isAttached;
|
||||
private bool _isDisposed;
|
||||
|
||||
private const double TapMovementThreshold = 10;
|
||||
private const long TapTimeThresholdMs = 500;
|
||||
|
||||
private readonly Dictionary<int, PointerGestureState> _gestureStates = new();
|
||||
|
||||
private record PointerGestureState(
|
||||
Point StartPosition,
|
||||
long StartTime,
|
||||
FileSystemItem Item,
|
||||
Border Border
|
||||
);
|
||||
|
||||
public FileManagerWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
||||
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(_currentCellSize * 0.25, 10, 20),
|
||||
Math.Clamp(_currentCellSize * 0.20, 8, 16));
|
||||
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActivePage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
|
||||
if (_isOnActivePage && _isAttached && !string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopFileManager
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
|
||||
_gestureStates.Clear();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = true;
|
||||
|
||||
if (_isOnActivePage)
|
||||
{
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = false;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
private void ApplyLayoutMetrics()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 4;
|
||||
|
||||
var buttonSize = Math.Clamp(32 * scale, 28, 40);
|
||||
var iconSize = Math.Clamp(14 * scale, 12, 18);
|
||||
var pathFontSize = Math.Clamp(13 * scale, 11, 16);
|
||||
|
||||
BackButton.Width = buttonSize;
|
||||
BackButton.Height = buttonSize;
|
||||
BackButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
HomeButton.Width = buttonSize;
|
||||
HomeButton.Height = buttonSize;
|
||||
HomeButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
RefreshButton.Width = buttonSize;
|
||||
RefreshButton.Height = buttonSize;
|
||||
RefreshButton.CornerRadius = new CornerRadius(buttonSize / 2);
|
||||
|
||||
PathTextBlock.FontSize = pathFontSize;
|
||||
|
||||
if (BackButton.Content is SymbolIcon backIcon)
|
||||
{
|
||||
backIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (HomeButton.Content is SymbolIcon homeIcon)
|
||||
{
|
||||
homeIcon.FontSize = iconSize;
|
||||
}
|
||||
|
||||
if (RefreshButton.Content is SymbolIcon refreshIcon)
|
||||
{
|
||||
refreshIcon.FontSize = iconSize;
|
||||
}
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 280d, 0.72, 2.4) : 1;
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 280d, 0.72, 2.4) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
|
||||
}
|
||||
|
||||
private void OnBackButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
if (_currentHistoryIndex > 0)
|
||||
{
|
||||
_currentHistoryIndex--;
|
||||
var path = _navigationHistory[_currentHistoryIndex];
|
||||
LoadDirectory(path, addToHistory: false);
|
||||
}
|
||||
else if (_currentHistoryIndex == 0 && _navigationHistory.Count > 0)
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHomeButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
NavigateToDrives();
|
||||
}
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
RefreshCurrentDirectory();
|
||||
}
|
||||
|
||||
private void OnItemPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border || border.DataContext is not FileSystemItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointer = e.GetCurrentPoint(border);
|
||||
var pointerId = e.Pointer.Id;
|
||||
var position = pointer.Position;
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
_gestureStates[pointerId] = new PointerGestureState(position, timestamp, item, border);
|
||||
|
||||
e.Pointer.Capture(border);
|
||||
}
|
||||
|
||||
private void OnItemPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.TryGetValue(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
if (distance > TapMovementThreshold)
|
||||
{
|
||||
_gestureStates.Remove(pointerId);
|
||||
e.Pointer.Capture(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (sender is not Border border)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pointerId = e.Pointer.Id;
|
||||
if (!_gestureStates.Remove(pointerId, out var state))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Pointer.Capture(null);
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(border);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(currentPoint.Position.X - state.StartPosition.X, 2) +
|
||||
Math.Pow(currentPoint.Position.Y - state.StartPosition.Y, 2)
|
||||
);
|
||||
|
||||
var elapsed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - state.StartTime;
|
||||
|
||||
if (distance <= TapMovementThreshold && elapsed <= TapTimeThresholdMs)
|
||||
{
|
||||
if (state.Item.IsDirectory)
|
||||
{
|
||||
LoadDirectory(state.Item.FullPath, addToHistory: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
OpenFile(state.Item.FullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void NavigateToDrives()
|
||||
{
|
||||
_navigationHistory.Clear();
|
||||
_currentHistoryIndex = -1;
|
||||
_currentPath = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var drives = new List<FileSystemItem>();
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!drive.IsReady)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = FileSystemItem.FromDriveInfo(drive);
|
||||
drives.Add(item);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to access drive: {drive?.Name}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "主目录",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
var linuxMountPoints = new[] { "/mnt", "/media", "/run/media" };
|
||||
foreach (var mount in linuxMountPoints)
|
||||
{
|
||||
if (Directory.Exists(mount))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = mount,
|
||||
FullPath = mount,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "根目录",
|
||||
FullPath = "/",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "用户",
|
||||
FullPath = "/Users",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "应用程序",
|
||||
FullPath = "/Applications",
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
|
||||
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(homePath) && Directory.Exists(homePath))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = "个人",
|
||||
FullPath = homePath,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
|
||||
if (Directory.Exists("/Volumes"))
|
||||
{
|
||||
foreach (var volume in Directory.GetDirectories("/Volumes"))
|
||||
{
|
||||
drives.Add(new FileSystemItem
|
||||
{
|
||||
Name = Path.GetFileName(volume),
|
||||
FullPath = volume,
|
||||
ItemType = FileSystemItemType.Directory
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RenderFileItems(drives);
|
||||
PathTextBlock.Text = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
|
||||
UpdateEmptyState(drives.Count == 0, "没有可用的位置");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", "Failed to load drives.", ex);
|
||||
ShowError("无法加载位置列表");
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDirectory(string path, bool addToHistory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
NavigateToDrives();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directoryInfo = new DirectoryInfo(path);
|
||||
|
||||
if (!directoryInfo.Exists)
|
||||
{
|
||||
ShowError("文件夹不存在");
|
||||
return;
|
||||
}
|
||||
|
||||
var items = new List<FileSystemItem>();
|
||||
|
||||
// 添加子文件夹
|
||||
try
|
||||
{
|
||||
var directories = directoryInfo.GetDirectories()
|
||||
.Where(d => (d.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(d => d.Name)
|
||||
.Select(FileSystemItem.FromDirectoryInfo);
|
||||
items.AddRange(directories);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件夹
|
||||
}
|
||||
|
||||
// 添加文件
|
||||
try
|
||||
{
|
||||
var files = directoryInfo.GetFiles()
|
||||
.Where(f => (f.Attributes & FileAttributes.Hidden) == 0)
|
||||
.OrderBy(f => f.Name)
|
||||
.Select(FileSystemItem.FromFileInfo);
|
||||
items.AddRange(files);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// 忽略无权限访问的文件
|
||||
}
|
||||
|
||||
RenderFileItems(items);
|
||||
_currentPath = path;
|
||||
PathTextBlock.Text = FormatPathForDisplay(path);
|
||||
|
||||
if (addToHistory)
|
||||
{
|
||||
// 移除当前位置之后的历史记录
|
||||
if (_currentHistoryIndex < _navigationHistory.Count - 1)
|
||||
{
|
||||
_navigationHistory.RemoveRange(_currentHistoryIndex + 1, _navigationHistory.Count - _currentHistoryIndex - 1);
|
||||
}
|
||||
|
||||
_navigationHistory.Add(path);
|
||||
_currentHistoryIndex = _navigationHistory.Count - 1;
|
||||
}
|
||||
|
||||
UpdateEmptyState(items.Count == 0, "文件夹为空");
|
||||
ErrorStatePanel.IsVisible = false;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
ShowError("没有权限访问此文件夹");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to load directory: {path}", ex);
|
||||
ShowError("无法加载文件夹内容");
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderFileItems(List<FileSystemItem> items)
|
||||
{
|
||||
FileItemsControl.ItemsSource = null;
|
||||
FileItemsControl.Items.Clear();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var itemControl = CreateFileItemControl(item);
|
||||
FileItemsControl.Items.Add(itemControl);
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreateFileItemControl(FileSystemItem item)
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var itemWidth = Math.Clamp(72 * scale, 64, 96);
|
||||
var itemHeight = Math.Clamp(80 * scale, 72, 108);
|
||||
var iconSize = Math.Clamp(32 * scale, 24, 40);
|
||||
var fontSize = Math.Clamp(11 * scale, 10, 14);
|
||||
|
||||
var textBrush = this.FindResource("AdaptiveTextPrimaryBrush") as IBrush ?? new SolidColorBrush(Colors.White);
|
||||
|
||||
var border = new Border
|
||||
{
|
||||
Width = itemWidth,
|
||||
Height = itemHeight,
|
||||
Margin = new Thickness(4),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Background = new SolidColorBrush(Colors.Transparent),
|
||||
Cursor = new Cursor(StandardCursorType.Hand),
|
||||
DataContext = item
|
||||
};
|
||||
|
||||
var grid = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("*,Auto"),
|
||||
Margin = new Thickness(4)
|
||||
};
|
||||
|
||||
var iconImage = CreateSystemIconImage(item, iconSize);
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = item.Name,
|
||||
FontSize = fontSize,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = textBrush
|
||||
};
|
||||
|
||||
if (iconImage is not null)
|
||||
{
|
||||
grid.Children.Add(iconImage);
|
||||
Grid.SetRow(iconImage, 0);
|
||||
}
|
||||
|
||||
grid.Children.Add(textBlock);
|
||||
Grid.SetRow(textBlock, 1);
|
||||
|
||||
border.Child = grid;
|
||||
|
||||
ToolTip.SetTip(border, item.Name);
|
||||
|
||||
border.PointerPressed += OnItemPointerPressed;
|
||||
border.PointerMoved += OnItemPointerMoved;
|
||||
border.PointerReleased += OnItemPointerReleased;
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private Control? CreateSystemIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
byte[]? pngBytes = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => GetDriveIconBytes(item.FullPath),
|
||||
FileSystemItemType.Directory => WindowsIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => WindowsIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => LinuxIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => LinuxIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => LinuxIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
pngBytes = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => MacIconService.TryGetDriveIconPngBytes(),
|
||||
FileSystemItemType.Directory => MacIconService.TryGetSystemFolderIconPngBytes(),
|
||||
_ => MacIconService.TryGetIconPngBytes(item.FullPath)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
pngBytes = null;
|
||||
}
|
||||
|
||||
if (pngBytes is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = new MemoryStream(pngBytes);
|
||||
var bitmap = new Bitmap(stream);
|
||||
return new Image
|
||||
{
|
||||
Source = bitmap,
|
||||
Width = iconSize,
|
||||
Height = iconSize,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
Stretch = Stretch.Uniform
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return CreateFallbackIconImage(item, iconSize);
|
||||
}
|
||||
|
||||
private static byte[]? GetDriveIconBytes(string drivePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(drivePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
if (Directory.Exists(drivePath))
|
||||
{
|
||||
return WindowsIconService.TryGetIconPngBytes(drivePath);
|
||||
}
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetDriveIconPngBytes();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return WindowsIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
return LinuxIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return MacIconService.TryGetSystemFolderIconPngBytes();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Control CreateFallbackIconImage(FileSystemItem item, double iconSize)
|
||||
{
|
||||
var symbol = item.ItemType switch
|
||||
{
|
||||
FileSystemItemType.Drive => FluentIcons.Common.Symbol.HardDrive,
|
||||
FileSystemItemType.Directory => FluentIcons.Common.Symbol.Folder,
|
||||
_ => FluentIcons.Common.Symbol.Document
|
||||
};
|
||||
|
||||
var iconBrush = item.ItemType == FileSystemItemType.File
|
||||
? this.FindResource("AdaptiveTextSecondaryBrush") as IBrush ?? new SolidColorBrush(Colors.Gray)
|
||||
: this.FindResource("AdaptiveAccentBrush") as IBrush ?? new SolidColorBrush(Colors.DodgerBlue);
|
||||
|
||||
return new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
FontSize = iconSize,
|
||||
Foreground = iconBrush,
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
}
|
||||
|
||||
private void RefreshCurrentDirectory()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_currentPath))
|
||||
{
|
||||
NavigateToDrives();
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadDirectory(_currentPath, addToHistory: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateEmptyState(bool isEmpty, string message)
|
||||
{
|
||||
EmptyStatePanel.IsVisible = isEmpty;
|
||||
EmptyStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = !isEmpty;
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorStatePanel.IsVisible = true;
|
||||
ErrorStateTextBlock.Text = message;
|
||||
FileItemsControl.IsVisible = false;
|
||||
EmptyStatePanel.IsVisible = false;
|
||||
}
|
||||
|
||||
private static void OpenFile(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(filePath)
|
||||
{
|
||||
UseShellExecute = true
|
||||
});
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
Process.Start("xdg-open", filePath);
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
Process.Start("open", filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("FileManagerWidget", $"Failed to open file: {filePath}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatPathForDisplay(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "此电脑" : "文件系统";
|
||||
}
|
||||
|
||||
var separator = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? '\\' : '/';
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
if (path.Length <= 3 && path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
var driveInfo = new DriveInfo(path.Substring(0, 1));
|
||||
if (!string.IsNullOrWhiteSpace(driveInfo.VolumeLabel))
|
||||
{
|
||||
return $"{driveInfo.VolumeLabel} ({path.Substring(0, 2)})";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (path == "/")
|
||||
{
|
||||
return "根目录";
|
||||
}
|
||||
|
||||
if (path == Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
|
||||
{
|
||||
return "主目录";
|
||||
}
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
if (path == "/Applications")
|
||||
{
|
||||
return "应用程序";
|
||||
}
|
||||
|
||||
if (path == "/Users")
|
||||
{
|
||||
return "用户";
|
||||
}
|
||||
|
||||
if (path.StartsWith("/Volumes/"))
|
||||
{
|
||||
return Path.GetFileName(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var parts = path.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length <= 3)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return $"{parts[0]}{separator}...{separator}{parts[^2]}{separator}{parts[^1]}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:symbol="using:FluentIcons.Common"
|
||||
x:Class="LanMountainDesktop.Views.Components.NotificationBoxWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Background="Transparent"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<!-- 主卡片 -->
|
||||
<Border x:Name="CardBorder"
|
||||
Background="#FCFCFD"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
|
||||
Padding="12,10">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- 头部 -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<fi:SymbolIcon x:Name="HeaderIcon" Symbol="{x:Static symbol:Symbol.MailInbox}" FontSize="16" />
|
||||
<TextBlock x:Name="HeaderTextBlock"
|
||||
Text="消息盒子"
|
||||
FontSize="15"
|
||||
FontWeight="SemiBold" />
|
||||
<Border x:Name="UnreadBadge"
|
||||
Background="#E24B2D"
|
||||
CornerRadius="8"
|
||||
Padding="5,2"
|
||||
IsVisible="False">
|
||||
<TextBlock x:Name="UnreadCountText"
|
||||
Foreground="White"
|
||||
FontSize="11"
|
||||
FontWeight="Bold" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Button x:Name="ClearButton"
|
||||
Grid.Column="1"
|
||||
IsVisible="False"
|
||||
Click="OnClearButtonClick"
|
||||
Padding="6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.Delete}" FontSize="14" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<ScrollViewer Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="NotificationListPanel" Spacing="6" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<TextBlock x:Name="EmptyStateText"
|
||||
Grid.Row="1"
|
||||
Text="暂无通知"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="13"
|
||||
IsVisible="False" />
|
||||
|
||||
<!-- 隐私模式遮罩 -->
|
||||
<Border x:Name="PrivacyOverlay"
|
||||
Grid.Row="1"
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
IsVisible="False">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="6">
|
||||
<fi:SymbolIcon Symbol="{x:Static symbol:Symbol.EyeOff}" FontSize="24" Foreground="#8B95A5" />
|
||||
<TextBlock Text="您有新的通知"
|
||||
Foreground="#8B95A5"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 底部状态 -->
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="2"
|
||||
FontSize="11"
|
||||
Foreground="#8B95A5"
|
||||
Margin="0,6,0,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,529 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class NotificationBoxWidget : UserControl,
|
||||
IDesktopComponentWidget,
|
||||
IComponentSettingsContextAware,
|
||||
IComponentRuntimeContextAware
|
||||
{
|
||||
private readonly List<NotificationItemControl> _notificationControls = [];
|
||||
private NotificationListenerService? _notificationService;
|
||||
private IComponentInstanceSettingsStore _componentSettings = null!;
|
||||
private ISettingsService _appSettingsService = null!;
|
||||
private AppSettingsSnapshot _appSettings = new();
|
||||
private ComponentSettingsSnapshot _componentSettingsSnapshot = new();
|
||||
private bool _isAttached;
|
||||
private bool _isPrivacyMode;
|
||||
private bool _isNightVisual;
|
||||
private double _currentCellSize = 48d;
|
||||
|
||||
public NotificationBoxWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
PointerPressed += (_, e) =>
|
||||
{
|
||||
if (e.Source == NotificationListPanel)
|
||||
{
|
||||
ClearSelection();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsContext(DesktopComponentSettingsContext context)
|
||||
{
|
||||
_componentSettings = context.ComponentSettingsStore;
|
||||
LoadSettings();
|
||||
RefreshUI();
|
||||
}
|
||||
|
||||
public void SetComponentRuntimeContext(DesktopComponentRuntimeContext context)
|
||||
{
|
||||
_notificationService = NotificationListenerServiceProvider.GetOrCreate(_appSettingsService);
|
||||
if (_notificationService != null)
|
||||
{
|
||||
_notificationService.NotificationReceived += OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved += OnNotificationRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
_isNightVisual = ResolveNightMode();
|
||||
LoadSettings();
|
||||
RefreshUI();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = false;
|
||||
|
||||
if (_notificationService != null)
|
||||
{
|
||||
_notificationService.NotificationReceived -= OnNotificationReceived;
|
||||
_notificationService.NotificationRemoved -= OnNotificationRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_isNightVisual = ResolveNightMode();
|
||||
UpdateAdaptiveLayout();
|
||||
}
|
||||
|
||||
private bool ResolveNightMode()
|
||||
{
|
||||
if (ActualThemeVariant == ThemeVariant.Dark)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ActualThemeVariant == ThemeVariant.Light)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
||||
value is ISolidColorBrush brush)
|
||||
{
|
||||
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
{
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
var r = ToLinear(color.R / 255d);
|
||||
var g = ToLinear(color.G / 255d);
|
||||
var b = ToLinear(color.B / 255d);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
private void LoadSettings()
|
||||
{
|
||||
var appSettingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_appSettingsService = appSettingsFacade.Settings;
|
||||
_appSettings = _appSettingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
_isPrivacyMode = _appSettings.NotificationBoxPrivacyMode;
|
||||
|
||||
_componentSettingsSnapshot = _componentSettings?.Load()
|
||||
?? new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
private void RefreshUI()
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
|
||||
var hasNotifications = _notificationService?.GetNotifications().Count > 0;
|
||||
PrivacyOverlay.IsVisible = _isPrivacyMode && hasNotifications && _notificationService?.GetUnreadCount() > 0;
|
||||
NotificationListPanel.IsVisible = !PrivacyOverlay.IsVisible;
|
||||
|
||||
var unreadCount = _notificationService?.GetUnreadCount() ?? 0;
|
||||
UnreadBadge.IsVisible = unreadCount > 0;
|
||||
UnreadCountText.Text = unreadCount.ToString();
|
||||
|
||||
ClearButton.IsVisible = _componentSettingsSnapshot.NotificationBoxShowClearButton
|
||||
&& hasNotifications;
|
||||
|
||||
UpdateStatusText();
|
||||
RenderNotifications();
|
||||
}
|
||||
|
||||
private void RenderNotifications()
|
||||
{
|
||||
NotificationListPanel.Children.Clear();
|
||||
_notificationControls.Clear();
|
||||
|
||||
if (_notificationService == null)
|
||||
{
|
||||
EmptyStateText.IsVisible = true;
|
||||
EmptyStateText.Text = "通知服务未启动";
|
||||
return;
|
||||
}
|
||||
|
||||
var notifications = _notificationService.GetNotifications();
|
||||
|
||||
if (notifications.Count == 0)
|
||||
{
|
||||
EmptyStateText.IsVisible = true;
|
||||
EmptyStateText.Text = "暂无通知";
|
||||
return;
|
||||
}
|
||||
|
||||
EmptyStateText.IsVisible = false;
|
||||
|
||||
notifications = ApplySorting(notifications);
|
||||
|
||||
var maxCount = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
|
||||
notifications = notifications.Take(maxCount).ToList();
|
||||
|
||||
if (_componentSettingsSnapshot.NotificationBoxGroupByApp)
|
||||
{
|
||||
RenderGroupedNotifications(notifications);
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderFlatNotifications(notifications);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<NotificationItem> ApplySorting(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
return _componentSettingsSnapshot.NotificationBoxSortOrder switch
|
||||
{
|
||||
"TimeAsc" => notifications.OrderBy(n => n.ReceivedTime).ToList(),
|
||||
"AppGroup" => notifications.OrderBy(n => n.AppName).ThenByDescending(n => n.ReceivedTime).ToList(),
|
||||
_ => notifications.OrderByDescending(n => n.ReceivedTime).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private void RenderFlatNotifications(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
foreach (var notification in notifications)
|
||||
{
|
||||
var control = CreateNotificationControl(notification);
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderGroupedNotifications(IReadOnlyList<NotificationItem> notifications)
|
||||
{
|
||||
var grouped = notifications.GroupBy(n => n.AppName).ToList();
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var groupHeader = new TextBlock
|
||||
{
|
||||
Text = group.Key,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B95A5")),
|
||||
Margin = new Thickness(0, 6, 0, 3)
|
||||
};
|
||||
NotificationListPanel.Children.Add(groupHeader);
|
||||
|
||||
foreach (var notification in group)
|
||||
{
|
||||
var control = CreateNotificationControl(notification);
|
||||
NotificationListPanel.Children.Add(control);
|
||||
_notificationControls.Add(control);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationItemControl CreateNotificationControl(NotificationItem notification)
|
||||
{
|
||||
var control = new NotificationItemControl(notification, _componentSettingsSnapshot, _isNightVisual);
|
||||
control.Clicked += OnNotificationClicked;
|
||||
control.MarkAsRead += OnMarkAsRead;
|
||||
return control;
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(object? sender, NotificationItem notification)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
RefreshUI();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNotificationRemoved(object? sender, string notificationId)
|
||||
{
|
||||
Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (!_isAttached) return;
|
||||
RefreshUI();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnNotificationClicked(object? sender, NotificationItem notification)
|
||||
{
|
||||
}
|
||||
|
||||
private void OnMarkAsRead(object? sender, NotificationItem notification)
|
||||
{
|
||||
_notificationService?.MarkAsRead(notification.Id);
|
||||
RefreshUI();
|
||||
}
|
||||
|
||||
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_notificationService?.ClearAll();
|
||||
RefreshUI();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void UpdateStatusText()
|
||||
{
|
||||
var total = _notificationService?.GetNotifications().Count ?? 0;
|
||||
var max = _componentSettingsSnapshot.NotificationBoxMaxDisplayCount;
|
||||
StatusTextBlock.Text = $"共 {total} 条" + (total > max ? $"(显{max})" : "");
|
||||
}
|
||||
|
||||
private void ClearSelection()
|
||||
{
|
||||
foreach (var control in _notificationControls)
|
||||
{
|
||||
control.IsSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateAdaptiveLayout()
|
||||
{
|
||||
var scale = Math.Clamp(_currentCellSize / 48.0, 0.7, 1.8);
|
||||
var fontScale = Math.Clamp(scale, 0.8, 1.4);
|
||||
|
||||
var cornerRadius = ResolveUnifiedMainRadiusValue();
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
CardBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
|
||||
HeaderTextBlock.FontSize = 15 * fontScale;
|
||||
HeaderIcon.FontSize = 16 * fontScale;
|
||||
UnreadCountText.FontSize = 11 * fontScale;
|
||||
EmptyStateText.FontSize = 13 * fontScale;
|
||||
StatusTextBlock.FontSize = 11 * fontScale;
|
||||
|
||||
var padding = Math.Clamp(12 * scale, 8, 20);
|
||||
var verticalPadding = Math.Clamp(10 * scale, 6, 16);
|
||||
CardBorder.Padding = new Thickness(padding, verticalPadding);
|
||||
|
||||
foreach (var control in _notificationControls)
|
||||
{
|
||||
control.UpdateTheme(_isNightVisual, fontScale);
|
||||
}
|
||||
}
|
||||
|
||||
private static double ResolveUnifiedMainRadiusValue() =>
|
||||
HostAppearanceThemeProvider.GetOrCreate().GetCurrent().CornerRadiusTokens.Lg.TopLeft;
|
||||
}
|
||||
|
||||
public class NotificationItemControl : Border
|
||||
{
|
||||
private readonly NotificationItem _item;
|
||||
private readonly ComponentSettingsSnapshot _settings;
|
||||
private bool _isPointerPressed;
|
||||
private Point _pointerPressedPosition;
|
||||
private bool _isNightVisual;
|
||||
|
||||
public NotificationItemControl(NotificationItem item, ComponentSettingsSnapshot settings, bool isNightVisual)
|
||||
{
|
||||
_item = item;
|
||||
_settings = settings;
|
||||
_isNightVisual = isNightVisual;
|
||||
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
CornerRadius = new CornerRadius(6);
|
||||
Padding = new Thickness(10, 6);
|
||||
Cursor = new Cursor(StandardCursorType.Hand);
|
||||
BorderBrush = _item.IsRead
|
||||
? new SolidColorBrush(Colors.Transparent)
|
||||
: new SolidColorBrush(Color.Parse("#E24B2D"));
|
||||
BorderThickness = _item.IsRead ? new Thickness(0) : new Thickness(2, 0, 0, 0);
|
||||
|
||||
BuildUI();
|
||||
|
||||
PointerPressed += OnPointerPressed;
|
||||
PointerReleased += OnPointerReleased;
|
||||
}
|
||||
|
||||
private void BuildUI()
|
||||
{
|
||||
var grid = new Grid { ColumnDefinitions = ColumnDefinitions.Parse("Auto,*,Auto") };
|
||||
|
||||
if (_settings.NotificationBoxShowAppIcon)
|
||||
{
|
||||
var iconBorder = new Border
|
||||
{
|
||||
Width = 28,
|
||||
Height = 28,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#4D5560") : Color.Parse("#E8EAED")),
|
||||
Margin = new Thickness(0, 0, 8, 0)
|
||||
};
|
||||
var iconText = new TextBlock
|
||||
{
|
||||
Text = _item.AppName.Length > 0 ? _item.AppName[0].ToString() : "?",
|
||||
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
FontSize = 12,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
|
||||
};
|
||||
iconBorder.Child = iconText;
|
||||
grid.Children.Add(iconBorder);
|
||||
}
|
||||
|
||||
var contentPanel = new StackPanel { Spacing = 1 };
|
||||
Grid.SetColumn(contentPanel, 1);
|
||||
|
||||
var titleBlock = new TextBlock
|
||||
{
|
||||
Text = _item.Title,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
FontSize = 12,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"))
|
||||
};
|
||||
|
||||
var contentBlock = new TextBlock
|
||||
{
|
||||
Text = _item.Content,
|
||||
FontSize = 11,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671")),
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 2,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
contentPanel.Children.Add(titleBlock);
|
||||
if (!string.IsNullOrWhiteSpace(_item.Content))
|
||||
{
|
||||
contentPanel.Children.Add(contentBlock);
|
||||
}
|
||||
grid.Children.Add(contentPanel);
|
||||
|
||||
if (_settings.NotificationBoxShowTimestamp)
|
||||
{
|
||||
var timeText = _settings.NotificationBoxTimeFormat == "Relative"
|
||||
? GetRelativeTime(_item.ReceivedTime)
|
||||
: _item.ReceivedTime.ToString("HH:mm");
|
||||
|
||||
var timeBlock = new TextBlock
|
||||
{
|
||||
Text = timeText,
|
||||
FontSize = 10,
|
||||
Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#8B95A5")),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Top,
|
||||
Margin = new Thickness(6, 0, 0, 0)
|
||||
};
|
||||
Grid.SetColumn(timeBlock, 2);
|
||||
grid.Children.Add(timeBlock);
|
||||
}
|
||||
|
||||
Child = grid;
|
||||
}
|
||||
|
||||
public void UpdateTheme(bool isNightVisual, double fontScale)
|
||||
{
|
||||
_isNightVisual = isNightVisual;
|
||||
Background = _item.IsRead
|
||||
? new SolidColorBrush(isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#F5F5F5"))
|
||||
: new SolidColorBrush(isNightVisual ? Color.Parse("#3D4250") : Color.Parse("#FFFFFF"));
|
||||
|
||||
if (Child is Grid grid)
|
||||
{
|
||||
foreach (var child in grid.Children)
|
||||
{
|
||||
if (child is StackPanel panel)
|
||||
{
|
||||
foreach (var textBlock in panel.Children.OfType<TextBlock>())
|
||||
{
|
||||
textBlock.FontSize *= fontScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
_isPointerPressed = true;
|
||||
_pointerPressedPosition = e.GetPosition(this);
|
||||
IsSelected = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
if (!_isPointerPressed) return;
|
||||
|
||||
_isPointerPressed = false;
|
||||
var releasePosition = e.GetPosition(this);
|
||||
var distance = Math.Sqrt(
|
||||
Math.Pow(releasePosition.X - _pointerPressedPosition.X, 2) +
|
||||
Math.Pow(releasePosition.Y - _pointerPressedPosition.Y, 2));
|
||||
|
||||
if (distance < 5)
|
||||
{
|
||||
Clicked?.Invoke(this, _item);
|
||||
MarkAsRead?.Invoke(this, _item);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private static string GetRelativeTime(DateTime time)
|
||||
{
|
||||
var diff = DateTime.Now - time;
|
||||
|
||||
if (diff.TotalMinutes < 1) return "刚刚";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}分前";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}小时前";
|
||||
return $"{(int)diff.TotalDays}天前";
|
||||
}
|
||||
|
||||
public bool IsSelected { get; set; }
|
||||
|
||||
public event EventHandler<NotificationItem>? Clicked;
|
||||
public event EventHandler<NotificationItem>? MarkAsRead;
|
||||
}
|
||||
|
||||
public static class NotificationListenerServiceProvider
|
||||
{
|
||||
private static NotificationListenerService? _instance;
|
||||
|
||||
public static NotificationListenerService GetOrCreate(ISettingsService settingsService)
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new NotificationListenerService(settingsService);
|
||||
_instance.InitializeAsync().ConfigureAwait(false);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ public partial class MainWindow
|
||||
Color PressedColor,
|
||||
Color DividerColor);
|
||||
|
||||
private readonly IPowerManagementService _powerService = PowerManagementServiceFactory.GetOrCreate();
|
||||
private bool _isPowerMenuOpen;
|
||||
private bool _isPowerMenuAnimating;
|
||||
|
||||
private void InitializeTaskbarProfileFlyout()
|
||||
{
|
||||
if (TaskbarProfileButton is null || TaskbarProfilePopup is null)
|
||||
@@ -98,6 +102,16 @@ public partial class MainWindow
|
||||
TaskbarProfileDisplayNameTextBlock.Text = profile.DisplayName;
|
||||
TaskbarProfileSettingsActionTextBlock.Text = L("tooltip.open_settings", "Settings");
|
||||
TaskbarProfileDesktopEditActionTextBlock.Text = L("button.component_library", "Edit Desktop");
|
||||
TaskbarProfilePowerActionTextBlock.Text = L("power.menu", "Power");
|
||||
TaskbarPowerTitleTextBlock.Text = L("power.title", "Power");
|
||||
TaskbarPowerBackTextBlock.Text = L("power.back", "Back");
|
||||
PowerShutdownTextBlock.Text = L("power.shutdown", "Shutdown");
|
||||
PowerRestartTextBlock.Text = L("power.restart", "Restart");
|
||||
PowerLogoutTextBlock.Text = L("power.logout", "Log Out");
|
||||
PowerSleepTextBlock.Text = L("power.sleep", "Sleep");
|
||||
PowerLockTextBlock.Text = L("power.lock_screen", "Lock Screen");
|
||||
|
||||
UpdatePowerMenuVisibility();
|
||||
ApplyTaskbarProfilePopupTheme(_appearanceThemeService.GetCurrent());
|
||||
|
||||
ToolTip.SetTip(TaskbarProfileButton, profile.DisplayName);
|
||||
@@ -216,6 +230,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
ResetPowerMenuState();
|
||||
RefreshTaskbarProfilePresentation();
|
||||
TaskbarProfilePopup.IsOpen = true;
|
||||
}
|
||||
@@ -279,6 +294,202 @@ public partial class MainWindow
|
||||
app?.OpenIndependentSettingsModule("MainWindowTaskbar");
|
||||
}
|
||||
|
||||
private void OnPowerMenuEnterClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
EnterPowerMenu();
|
||||
}
|
||||
|
||||
private void OnPowerMenuBackClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ExitPowerMenu();
|
||||
}
|
||||
|
||||
private void ResetPowerMenuState()
|
||||
{
|
||||
_isPowerMenuOpen = false;
|
||||
_isPowerMenuAnimating = false;
|
||||
|
||||
if (TaskbarProfileMainPanel is not null)
|
||||
{
|
||||
TaskbarProfileMainPanel.IsVisible = true;
|
||||
TaskbarProfileMainPanel.Opacity = 1d;
|
||||
}
|
||||
|
||||
if (TaskbarProfilePowerPanel is not null)
|
||||
{
|
||||
TaskbarProfilePowerPanel.IsVisible = false;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
var transform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (transform is not null) transform.X = 340d;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePowerMenuVisibility()
|
||||
{
|
||||
var supported = _powerService.IsShutdownSupported ||
|
||||
_powerService.IsRestartSupported ||
|
||||
_powerService.IsLogoutSupported ||
|
||||
_powerService.IsSleepSupported ||
|
||||
_powerService.IsLockSupported;
|
||||
|
||||
if (TaskbarProfilePowerActionButton is not null)
|
||||
{
|
||||
TaskbarProfilePowerActionButton.IsVisible = supported;
|
||||
}
|
||||
}
|
||||
|
||||
private async void EnterPowerMenu()
|
||||
{
|
||||
if (_isPowerMenuAnimating || _isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
|
||||
return;
|
||||
|
||||
_isPowerMenuAnimating = true;
|
||||
|
||||
TaskbarProfilePowerPanel.IsVisible = true;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (powerTransform is not null) powerTransform.X = 340d;
|
||||
|
||||
await Task.Delay(16);
|
||||
|
||||
TaskbarProfileMainPanel.Opacity = 0d;
|
||||
TaskbarProfilePowerPanel.Opacity = 1d;
|
||||
if (powerTransform is not null) powerTransform.X = 0d;
|
||||
|
||||
await Task.Delay(280);
|
||||
|
||||
TaskbarProfileMainPanel.IsVisible = false;
|
||||
_isPowerMenuOpen = true;
|
||||
_isPowerMenuAnimating = false;
|
||||
}
|
||||
|
||||
private async void ExitPowerMenu()
|
||||
{
|
||||
if (_isPowerMenuAnimating || !_isPowerMenuOpen || TaskbarProfileMainPanel is null || TaskbarProfilePowerPanel is null)
|
||||
return;
|
||||
|
||||
_isPowerMenuAnimating = true;
|
||||
|
||||
TaskbarProfileMainPanel.IsVisible = true;
|
||||
TaskbarProfileMainPanel.Opacity = 0d;
|
||||
var powerTransform = TaskbarProfilePowerPanel.RenderTransform as TranslateTransform;
|
||||
if (powerTransform is not null) powerTransform.X = 0d;
|
||||
|
||||
await Task.Delay(16);
|
||||
|
||||
TaskbarProfileMainPanel.Opacity = 1d;
|
||||
TaskbarProfilePowerPanel.Opacity = 0d;
|
||||
if (powerTransform is not null) powerTransform.X = 340d;
|
||||
|
||||
await Task.Delay(280);
|
||||
|
||||
TaskbarProfilePowerPanel.IsVisible = false;
|
||||
_isPowerMenuOpen = false;
|
||||
_isPowerMenuAnimating = false;
|
||||
}
|
||||
|
||||
private async void OnPowerShutdownClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_powerService.ShowNativePowerUI(PowerAction.Shutdown);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowPowerConfirmDialogAsync(L("power.shutdown_confirm_title", "Shutdown"),
|
||||
L("power.shutdown_confirm_message", "Are you sure you want to shut down this computer?"),
|
||||
() => _powerService.ShutdownAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPowerRestartClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_powerService.ShowNativePowerUI(PowerAction.Restart);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ShowPowerConfirmDialogAsync(L("power.restart_confirm_title", "Restart"),
|
||||
L("power.restart_confirm_message", "Are you sure you want to restart this computer?"),
|
||||
() => _powerService.RestartAsync());
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnPowerLogoutClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
await ShowPowerConfirmDialogAsync(L("power.logout_confirm_title", "Log Out"),
|
||||
L("power.logout_confirm_message", "Are you sure you want to log out?"),
|
||||
() => _powerService.LogoutAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerSleepClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
|
||||
await ShowPowerConfirmDialogAsync(L("power.sleep_confirm_title", "Sleep"),
|
||||
L("power.sleep_confirm_message", "Are you sure you want to put the computer to sleep?"),
|
||||
() => _powerService.SleepAsync());
|
||||
}
|
||||
|
||||
private async void OnPowerLockClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ClosePopupIfOpen();
|
||||
await _powerService.LockAsync();
|
||||
}
|
||||
|
||||
private async Task ShowPowerConfirmDialogAsync(string title, string message, Func<Task> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = title,
|
||||
Content = message,
|
||||
PrimaryButtonText = L("power.confirm_yes", "Yes"),
|
||||
SecondaryButtonText = L("power.confirm_cancel", "Cancel")
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
await action();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Error("PowerMenu", $"Dialog error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClosePopupIfOpen()
|
||||
{
|
||||
if (TaskbarProfilePopup is not null && TaskbarProfilePopup.IsOpen)
|
||||
{
|
||||
TaskbarProfilePopup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnCloseComponentLibraryClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_componentLibraryWindowService.Close(this);
|
||||
|
||||
@@ -398,69 +398,215 @@
|
||||
<Border x:Name="TaskbarProfilePopupPanel"
|
||||
Classes="taskbar-profile-popup-panel"
|
||||
Margin="0,0,0,10">
|
||||
<StackPanel Width="280"
|
||||
Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||
Classes="taskbar-profile-popup-avatar"
|
||||
Width="44"
|
||||
Height="44"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False" />
|
||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
||||
Classes="taskbar-profile-popup-primary"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="U" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Grid Width="340">
|
||||
<Grid x:Name="TaskbarProfileMainPanel"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="TaskbarProfileHeaderAvatarBorder"
|
||||
Classes="taskbar-profile-popup-avatar"
|
||||
Width="44"
|
||||
Height="44"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Image x:Name="TaskbarProfileHeaderAvatarImage"
|
||||
Stretch="UniformToFill"
|
||||
IsVisible="False" />
|
||||
<TextBlock x:Name="TaskbarProfileHeaderAvatarFallbackText"
|
||||
Classes="taskbar-profile-popup-primary"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="U" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||
Classes="taskbar-profile-popup-title"
|
||||
Text="User" />
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="TaskbarProfileDisplayNameTextBlock"
|
||||
Classes="taskbar-profile-popup-title"
|
||||
Text="User" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="TaskbarProfilePopupDivider"
|
||||
Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
|
||||
<Button x:Name="TaskbarProfileSettingsActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenSettingsClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Settings" />
|
||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Settings" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenComponentLibraryClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Pencil" />
|
||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Edit Desktop" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfilePowerActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerMenuEnterClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="TaskbarProfilePowerActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Power" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border x:Name="TaskbarProfilePopupDivider"
|
||||
Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}" />
|
||||
<Grid x:Name="TaskbarProfilePowerPanel"
|
||||
IsVisible="False"
|
||||
Opacity="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Fast}" />
|
||||
</Transitions>
|
||||
</Grid.Transitions>
|
||||
<Grid.RenderTransform>
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X"
|
||||
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
|
||||
Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
</Grid.RenderTransform>
|
||||
<StackPanel Spacing="8">
|
||||
<Button x:Name="TaskbarPowerBackButton"
|
||||
Padding="4,6"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnPowerMenuBackClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:SymbolIcon Classes="icon-s"
|
||||
Symbol="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="TaskbarPowerBackTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Back" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="TaskbarProfileSettingsActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenSettingsClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Settings" />
|
||||
<TextBlock x:Name="TaskbarProfileSettingsActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Settings" />
|
||||
</Grid>
|
||||
</Button>
|
||||
<TextBlock x:Name="TaskbarPowerTitleTextBlock"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TaskbarProfilePopupTextBrush}"
|
||||
Margin="2,6,0,0"
|
||||
Text="Power" />
|
||||
|
||||
<Button x:Name="TaskbarProfileDesktopEditActionButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnOpenComponentLibraryClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Pencil" />
|
||||
<TextBlock x:Name="TaskbarProfileDesktopEditActionTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Edit Desktop" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<Border Height="1"
|
||||
Background="{DynamicResource TaskbarProfilePopupDividerBrush}"
|
||||
Margin="0,4" />
|
||||
|
||||
<Button x:Name="PowerShutdownButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerShutdownClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Power" />
|
||||
<TextBlock x:Name="PowerShutdownTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Shutdown" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerRestartButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerRestartClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Refresh" />
|
||||
<TextBlock x:Name="PowerRestartTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Restart" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerLogoutButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLogoutClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="ExitToApp" />
|
||||
<TextBlock x:Name="PowerLogoutTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Log Out" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerSleepButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerSleepClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="WeatherNight" />
|
||||
<TextBlock x:Name="PowerSleepTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Sleep" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="PowerLockButton"
|
||||
Classes="taskbar-profile-popup-action"
|
||||
Click="OnPowerLockClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<mi:MaterialIcon Classes="taskbar-profile-popup-action-icon"
|
||||
Kind="Lock" />
|
||||
<TextBlock x:Name="PowerLockTextBlock"
|
||||
Grid.Column="1"
|
||||
Classes="taskbar-profile-popup-action-text"
|
||||
Text="Lock Screen" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
|
||||
@@ -258,18 +258,43 @@ public partial class TransparentOverlayWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
var control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
// 【修复问题3】尝试从现有窗口中获取组件实例,避免重新创建导致状态丢失
|
||||
var control = TryGetExistingControl(placement.PlacementId);
|
||||
if (control is null)
|
||||
{
|
||||
// 如果没有现有实例,才创建新的
|
||||
control = descriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_settingsFacade,
|
||||
placement.PlacementId);
|
||||
}
|
||||
|
||||
RenderComponent(placement.PlacementId, control, placement.X, placement.Y, placement.Width, placement.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 【修复问题3】尝试从现有的小窗口中获取组件控件实例
|
||||
/// </summary>
|
||||
private Control? TryGetExistingControl(string placementId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var manager = FusedDesktopManagerServiceFactory.GetOrCreate();
|
||||
// 通过反射或公共 API 获取现有窗口中的控件
|
||||
// 这里需要 FusedDesktopManagerService 提供获取控件的方法
|
||||
// 暂时返回 null,后续需要扩展接口
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 移除组件
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user