a158 2 settimane fa
parent
commit
3b873ad2f7
71 ha cambiato i file con 4258 aggiunte e 496 eliminazioni
  1. 5 7
      Client/Client.vcxproj
  2. 9 6
      Client/Client.vcxproj.filters
  3. BIN
      Client/NIM-cache/cacheIndex.txt
  4. BIN
      Client/NIM-cache/part2/NIM1.cab
  5. BIN
      Client/NIM-cache/part2/output-info.ini
  6. 503 0
      Client/NIM.aip
  7. 326 0
      Client/NIM.back(23.8).aip
  8. 0 27
      Client/config/config.ini
  9. 19 0
      Client/res/app.manifest
  10. 1 0
      Client/res/app.rc
  11. 11 9
      Client/res/qss/app.qss
  12. 29 4
      Client/src/app/main.cpp
  13. 9 5
      Client/src/auth/ChangePasswordDialog.cpp
  14. 25 7
      Client/src/auth/ForgotPasswordDialog.cpp
  15. 5 0
      Client/src/auth/ForgotPasswordDialog.h
  16. 2 2
      Client/src/auth/LoginWindow.cpp
  17. 27 9
      Client/src/auth/RegisterDialog.cpp
  18. 5 0
      Client/src/auth/RegisterDialog.h
  19. 44 16
      Client/src/chat/StickerPanel.cpp
  20. 52 147
      Client/src/common/AppConfig.cpp
  21. 31 39
      Client/src/common/AppConfig.h
  22. 23 11
      Client/src/common/AppTheme.cpp
  23. 2 1
      Client/src/common/AppTheme.h
  24. 16 0
      Client/src/common/ClientConfig.h
  25. 2 3
      Client/src/common/LocalStore.cpp
  26. 53 8
      Client/src/common/UiHelpers.cpp
  27. 75 1
      Client/src/common/UiHelpers.h
  28. 1 1
      Client/src/common/Updater.cpp
  29. 2 24
      Client/src/common/UserSettings.cpp
  30. 1 1
      Client/src/common/UserSettings.h
  31. 506 107
      Client/src/mainframe/Client.cpp
  32. 21 0
      Client/src/mainframe/Client.h
  33. 996 0
      Client/src/mainframe/FavoritePanel.cpp
  34. 74 0
      Client/src/mainframe/FavoritePanel.h
  35. BIN
      Client/src/mainframe/SettingsDialog.cpp
  36. 72 8
      Client/src/network/ImClient.cpp
  37. 8 0
      Client/src/network/ImClient.h
  38. 7 1
      Client/src/network/Protocol.cpp
  39. 3 1
      Client/src/network/Protocol.h
  40. 73 0
      Client/src/widgets/ConfirmDialog.cpp
  41. 17 0
      Client/src/widgets/ConfirmDialog.h
  42. 11 1
      Client/src/widgets/ImagePreviewDialog.cpp
  43. 3 1
      Client/src/widgets/ImagePreviewDialog.h
  44. 5 1
      common/i18n.h
  45. 34 12
      common/protocol_def.h
  46. 92 18
      docs/protocol.md
  47. 5 1
      server/config/config.ini.example
  48. 16 0
      server/sql/init.sql
  49. 19 0
      server/sql/migrate.sql
  50. 2 0
      server/src/app/application.cpp
  51. 8 0
      server/src/config/app_config.cpp
  52. 6 0
      server/src/config/app_config.h
  53. 138 0
      server/src/db/favorite_store.cpp
  54. 38 0
      server/src/db/favorite_store.h
  55. 14 0
      server/src/db/file_store.cpp
  56. 1 0
      server/src/db/file_store.h
  57. 122 8
      server/src/db/message_store.cpp
  58. 5 1
      server/src/db/message_store.h
  59. 2 0
      server/src/db/mysql_service.cpp
  60. 3 0
      server/src/im/handler.cpp
  61. 1 0
      server/src/im/handler.h
  62. 1 0
      server/src/im/handlers/account_handler.cpp
  63. 496 0
      server/src/im/handlers/favorite_handler.cpp
  64. 19 0
      server/src/im/handlers/favorite_handler.h
  65. 0 1
      server/src/im/handlers/friend_handler.cpp
  66. 90 1
      server/src/im/handlers/message_handler.cpp
  67. 1 0
      server/src/im/handlers/message_handler.h
  68. 3 3
      server/src/im/handlers/system_handler.cpp
  69. 2 2
      server/src/im/handlers/system_handler.h
  70. 61 1
      server/src/third/cos.cpp
  71. 5 0
      server/src/third/cos.h

+ 5 - 7
Client/Client.vcxproj

@@ -85,9 +85,7 @@
       <GenerateDebugInformation>true</GenerateDebugInformation>
     </Link>
     <PostBuildEvent>
-      <Command>if not exist "$(OutDir)config" mkdir "$(OutDir)config"
-copy /Y "$(ProjectDir)config\config.ini" "$(OutDir)config\config.ini"
-if not exist "$(OutDir)sqldrivers" mkdir "$(OutDir)sqldrivers"
+      <Command>if not exist "$(OutDir)sqldrivers" mkdir "$(OutDir)sqldrivers"
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlite.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlite.dll" "$(OutDir)sqldrivers\qsqlite.dll"
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlited.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlited.dll" "$(OutDir)sqldrivers\qsqlited.dll"
 if not exist "$(OutDir)imageformats" mkdir "$(OutDir)imageformats"
@@ -117,9 +115,7 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
       <OptimizeReferences>true</OptimizeReferences>
     </Link>
     <PostBuildEvent>
-      <Command>if not exist "$(OutDir)config" mkdir "$(OutDir)config"
-copy /Y "$(ProjectDir)config\config.ini" "$(OutDir)config\config.ini"
-if not exist "$(OutDir)sqldrivers" mkdir "$(OutDir)sqldrivers"
+      <Command>if not exist "$(OutDir)sqldrivers" mkdir "$(OutDir)sqldrivers"
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlite.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlite.dll" "$(OutDir)sqldrivers\qsqlite.dll"
 if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlited.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\sqldrivers\qsqlited.dll" "$(OutDir)sqldrivers\qsqlited.dll"
 if not exist "$(OutDir)imageformats" mkdir "$(OutDir)imageformats"
@@ -147,9 +143,11 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
     <QtMoc Include="src\chat\AvatarCache.h" />
     <QtMoc Include="src\chat\FileCache.h" />
     <QtMoc Include="src\chat\StickerPanel.h" />
+    <QtMoc Include="src\mainframe\FavoritePanel.h" />
     <ClInclude Include="src\common\AppTheme.h" />
     <ClInclude Include="src\common\UiHelpers.h" />
     <ClInclude Include="src\common\AppConfig.h" />
+    <ClInclude Include="src\common\ClientConfig.h" />
     <ClInclude Include="src\common\UserSettings.h" />
     <ClInclude Include="src\common\Logger.h" />
     <ClInclude Include="src\common\LocalStore.h" />
@@ -188,13 +186,13 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
     <ClCompile Include="src\mainframe\PickFriendDialog.cpp" />
     <ClCompile Include="src\mainframe\AddFriendDialog.cpp" />
     <ClCompile Include="src\mainframe\FriendRequestDialog.cpp" />
+    <ClCompile Include="src\mainframe\FavoritePanel.cpp" />
     <ClCompile Include="src\chat\StickerCache.cpp" />
     <ClCompile Include="src\chat\AvatarCache.cpp" />
     <ClCompile Include="src\chat\FileCache.cpp" />
     <ClInclude Include="src\chat\FileTypeIcon.h" />
     <ClCompile Include="src\chat\FileTypeIcon.cpp" />
     <ClCompile Include="src\chat\StickerPanel.cpp" />
-    <None Include="config\config.ini" />
     <ResourceCompile Include="res\app.rc" />
   </ItemGroup>
   <Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />

+ 9 - 6
Client/Client.vcxproj.filters

@@ -43,9 +43,6 @@
     <Filter Include="res\images">
       <UniqueIdentifier>{b1111111-1111-1111-1111-111111111112}</UniqueIdentifier>
     </Filter>
-    <Filter Include="config">
-      <UniqueIdentifier>{c1111111-1111-1111-1111-111111111111}</UniqueIdentifier>
-    </Filter>
   </ItemGroup>
   <ItemGroup>
     <QtRcc Include="res\res.qrc">
@@ -75,6 +72,12 @@
     <ClCompile Include="src\mainframe\FriendRequestDialog.cpp">
       <Filter>src\mainframe</Filter>
     </ClCompile>
+    <QtMoc Include="src\mainframe\FavoritePanel.h">
+      <Filter>src\mainframe</Filter>
+    </QtMoc>
+    <ClCompile Include="src\mainframe\FavoritePanel.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
     <QtMoc Include="src\chat\StickerCache.h">
       <Filter>src\chat</Filter>
     </QtMoc>
@@ -141,6 +144,9 @@
     <ClInclude Include="src\common\AppConfig.h">
       <Filter>src\common</Filter>
     </ClInclude>
+    <ClInclude Include="src\common\ClientConfig.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
     <ClCompile Include="src\common\AppConfig.cpp">
       <Filter>src\common</Filter>
     </ClCompile>
@@ -228,9 +234,6 @@
     <ClCompile Include="src\auth\ChangePasswordDialog.cpp">
       <Filter>src\auth</Filter>
     </ClCompile>
-    <None Include="config\config.ini">
-      <Filter>config</Filter>
-    </None>
     <ResourceCompile Include="res\app.rc">
       <Filter>res</Filter>
     </ResourceCompile>

BIN
Client/NIM-cache/cacheIndex.txt


BIN
Client/NIM-cache/part2/NIM1.cab


BIN
Client/NIM-cache/part2/output-info.ini


+ 503 - 0
Client/NIM.aip

@@ -0,0 +1,503 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<DOCUMENT Type="Advanced Installer" CreateVersion="23.8" version="23.8" PreviousModules="simple" Modules="professional" RootPath="." Language="zh" Id="{A6B93378-6C66-425C-86A4-FFC00F034A86}">
+  <COMPONENT cid="caphyon.advinst.msicomp.ProjectOptionsComponent">
+    <ROW Name="HiddenItems" Value="ActSyncAppComponent;CPLAppletComponent;AutorunComponent;GameUxComponent;SilverlightSlnComponent;SharePointSlnComponent"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiPropsComponent">
+    <ROW Property="AI_BITMAP_DISPLAY_MODE" Value="0"/>
+    <ROW Property="AI_EXTERNALUIUNINSTALLERNAME" MultiBuildValue="DefaultBuild:aiui"/>
+    <ROW Property="AI_FINDEXE_TITLE" Value="选择 [|ProductName] 安装包" ValueLocId="AI.Property.FindExeTitle"/>
+    <ROW Property="AI_PRODUCTNAME_ARP" Value="NIM"/>
+    <ROW Property="AI_UNINSTALLER" Value="msiexec.exe"/>
+    <ROW Property="ALLUSERS" Value="1"/>
+    <ROW Property="ARPCOMMENTS" Value="此 Installer 数据库包含了安装 [|ProductName] 所需的逻辑和数据。" ValueLocId="*"/>
+    <ROW Property="ARPNOREPAIR" Value="1" MultiBuildValue="DefaultBuild:1"/>
+    <ROW Property="ARPPRODUCTICON" Value="app.exe" Type="8"/>
+    <ROW Property="ARPSYSTEMCOMPONENT" Value="1"/>
+    <ROW Property="Manufacturer" Value="NIM"/>
+    <ROW Property="ProductCode" Value="2052:{301E1BD3-81F4-41C2-B761-ADCFBE42D684} " Type="16"/>
+    <ROW Property="ProductLanguage" Value="1033"/>
+    <ROW Property="ProductName" Value="NIM"/>
+    <ROW Property="ProductVersion" Value="1.0.0" Options="32"/>
+    <ROW Property="SecureCustomProperties" Value="OLDPRODUCTS;AI_NEWERPRODUCTFOUND;AI_SETUPEXEPATH;SETUPEXEDIR"/>
+    <ROW Property="UpgradeCode" Value="{51BDF350-8DE6-4E99-82DF-C18E41DC3A55}"/>
+    <ROW Property="WindowsType9X" MultiBuildValue="DefaultBuild:Windows 9x/ME" ValueLocId="-"/>
+    <ROW Property="WindowsType9XDisplay" MultiBuildValue="DefaultBuild:Windows 9x/ME" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT40" MultiBuildValue="DefaultBuild:Windows NT 4.0" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT40Display" MultiBuildValue="DefaultBuild:Windows NT 4.0" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT50" MultiBuildValue="DefaultBuild:Windows 2000" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT50Display" MultiBuildValue="DefaultBuild:Windows 2000" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT5X" MultiBuildValue="DefaultBuild:Windows XP/2003" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT5XDisplay" MultiBuildValue="DefaultBuild:Windows XP/2003" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT60" MultiBuildValue="DefaultBuild:Windows Vista/Server 2008" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT60Display" MultiBuildValue="DefaultBuild:Windows Vista/Server 2008" ValueLocId="-"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiDirsComponent">
+    <ROW Directory="APPDIR" Directory_Parent="TARGETDIR" DefaultDir="APPDIR:." IsPseudoRoot="1" DirectoryOptions="3"/>
+    <ROW Directory="SHORTCUTDIR" Directory_Parent="TARGETDIR" DefaultDir="SHORTC~1|SHORTCUTDIR" IsPseudoRoot="1"/>
+    <ROW Directory="TARGETDIR" DefaultDir="SourceDir"/>
+    <ROW Directory="bearer_Dir" Directory_Parent="APPDIR" DefaultDir="bearer" DirectoryOptions="3"/>
+    <ROW Directory="iconengines_Dir" Directory_Parent="APPDIR" DefaultDir="ICONEN~1|iconengines" DirectoryOptions="3"/>
+    <ROW Directory="imageformats_Dir" Directory_Parent="APPDIR" DefaultDir="IMAGEF~1|imageformats" DirectoryOptions="3"/>
+    <ROW Directory="platforms_Dir" Directory_Parent="APPDIR" DefaultDir="PLATFO~1|platforms" DirectoryOptions="3"/>
+    <ROW Directory="rdparty_Dir" Directory_Parent="APPDIR" DefaultDir="3rdparty" DirectoryOptions="3"/>
+    <ROW Directory="sqldrivers_Dir" Directory_Parent="APPDIR" DefaultDir="SQLDRI~1|sqldrivers" DirectoryOptions="3"/>
+    <ROW Directory="styles_Dir" Directory_Parent="APPDIR" DefaultDir="styles" DirectoryOptions="3"/>
+    <ROW Directory="translations_Dir" Directory_Parent="APPDIR" DefaultDir="TRANSL~1|translations" DirectoryOptions="3"/>
+    <ROW Directory="x64_Dir" Directory_Parent="z_Dir" DefaultDir="x64" DirectoryOptions="3"/>
+    <ROW Directory="z_Dir" Directory_Parent="rdparty_Dir" DefaultDir="7z" DirectoryOptions="3"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.SideBySideGuidComponent">
+    <ROW Component="APPDIR" Value="{17EB4A42-2A9A-4678-8913-45C3FED5114C}"/>
+    <ROW Component="ProductInformation" Value="{A2129477-7040-4D1C-ACCD-1BDC2D26D619}"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiCompsComponent">
+    <ROW Component="AI_CustomARPName" ComponentId="{381AD665-14B1-4D2F-A20C-C4D23AAF7B55}" Directory_="APPDIR" Attributes="4" KeyPath="DisplayName" Options="1"/>
+    <ROW Component="AI_ExePath" ComponentId="{7BAECFF1-5B36-41B6-B09B-B1AFD6AF570C}" Directory_="APPDIR" Attributes="4" KeyPath="AI_ExePath"/>
+    <ROW Component="APPDIR" ComponentId="{17EB4A42-2A9A-4678-8913-45C3FED5114C}" Directory_="APPDIR" Attributes="0"/>
+    <ROW Component="D3Dcompiler_47.dll" ComponentId="{5BAF1A44-F31B-4A04-B43E-E36EC232E41A}" Directory_="APPDIR" Attributes="256" KeyPath="D3Dcompiler_47.dll"/>
+    <ROW Component="NIM.exe" ComponentId="{2A252122-DDF6-45FD-A675-F792B79F7DBB}" Directory_="APPDIR" Attributes="256" KeyPath="NIM.exe"/>
+    <ROW Component="ProductInformation" ComponentId="{A2129477-7040-4D1C-ACCD-1BDC2D26D619}" Directory_="APPDIR" Attributes="4" KeyPath="Version"/>
+    <ROW Component="Qt5Core.dll" ComponentId="{3DE8D5D4-40FB-4351-BE3C-E466B4609985}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Core.dll"/>
+    <ROW Component="Qt5Gui.dll" ComponentId="{6BA7A9A8-165F-4484-87A6-9DFB355EC135}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Gui.dll"/>
+    <ROW Component="Qt5Network.dll" ComponentId="{E8B0BB9D-9BE4-4F93-9ED8-77DA30C23025}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Network.dll"/>
+    <ROW Component="Qt5Sql.dll" ComponentId="{AE778057-7818-4D84-82D2-6E9B6398453C}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Sql.dll"/>
+    <ROW Component="Qt5Svg.dll" ComponentId="{265CC092-BAD1-43FE-8F16-AFB948E12ED9}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Svg.dll"/>
+    <ROW Component="Qt5Widgets.dll" ComponentId="{8BF8C8F6-5314-48DF-8269-F14BA2C9BADA}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Widgets.dll"/>
+    <ROW Component="SHORTCUTDIR" ComponentId="{C47A991D-2A1B-45C9-9891-20FD513B690E}" Directory_="SHORTCUTDIR" Attributes="0"/>
+    <ROW Component="libEGL.dll" ComponentId="{87F9872B-96E3-4187-A4BB-C4D107F37450}" Directory_="APPDIR" Attributes="256" KeyPath="libEGL.dll"/>
+    <ROW Component="libGLESV2.dll" ComponentId="{9480B67A-5356-48FD-A8A6-811FAE40010B}" Directory_="APPDIR" Attributes="256" KeyPath="libGLESV2.dll"/>
+    <ROW Component="msvcp140.dll" ComponentId="{8E5F3529-6D6A-407E-BC79-62417BFD9564}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140.dll"/>
+    <ROW Component="msvcp140_1.dll" ComponentId="{FE8EF5B1-E029-48CE-929F-DC3DC4A8D683}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_1.dll"/>
+    <ROW Component="msvcp140_2.dll" ComponentId="{1028346E-8F61-4055-B61B-1970EEFF5EDE}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_2.dll"/>
+    <ROW Component="msvcp140_atomic_wait.dll" ComponentId="{32F154F2-E48F-41BC-B3F5-6256DAF9AE03}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_atomic_wait.dll"/>
+    <ROW Component="msvcp140_clr0400.dll" ComponentId="{615635D5-9B1F-416D-8DDB-A478970D9ADA}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_clr0400.dll"/>
+    <ROW Component="msvcp140_codecvt_ids.dll" ComponentId="{6E80FBDB-477C-4AAD-B2A7-C0A7B2A53E77}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_codecvt_ids.dll"/>
+    <ROW Component="opengl32sw.dll" ComponentId="{F26C7E7D-65C8-45DD-8E5B-2E56B1F1D150}" Directory_="APPDIR" Attributes="256" KeyPath="opengl32sw.dll"/>
+    <ROW Component="qgenericbearer.dll" ComponentId="{262D9BD1-DC24-4071-8801-B12A5F72DAC1}" Directory_="bearer_Dir" Attributes="256" KeyPath="qgenericbearer.dll"/>
+    <ROW Component="qgif.dll" ComponentId="{AA785284-81A8-41F2-A583-1F48109DBEEF}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qgif.dll"/>
+    <ROW Component="qgifd.dll" ComponentId="{D76D3C5C-FB93-444F-A276-9B6FCEBA38DB}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qgifd.dll"/>
+    <ROW Component="qicns.dll" ComponentId="{39419932-14CF-44AA-A76C-5B0CD658D956}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qicns.dll"/>
+    <ROW Component="qico.dll" ComponentId="{A50D622B-6657-485C-9093-83F7239C5BEB}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qico.dll"/>
+    <ROW Component="qjpeg.dll" ComponentId="{92099A72-E110-4C47-ABD7-16974630839A}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qjpeg.dll"/>
+    <ROW Component="qjpegd.dll" ComponentId="{15D13389-144B-47ED-A7D5-A6F74A6323AF}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qjpegd.dll"/>
+    <ROW Component="qsqlite.dll" ComponentId="{857E1F9A-3C16-49BA-A7BA-C1FA605E5804}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlite.dll"/>
+    <ROW Component="qsqlited.dll" ComponentId="{CB9CBEB3-592A-4C0F-9C7F-8D694D3324DD}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlited.dll"/>
+    <ROW Component="qsqlodbc.dll" ComponentId="{2FE1763F-C5C8-4BA4-860F-ACEF05FA938D}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlodbc.dll"/>
+    <ROW Component="qsqlpsql.dll" ComponentId="{0F26C215-9C07-423E-B6B4-D0415E9F0A2B}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlpsql.dll"/>
+    <ROW Component="qsvg.dll" ComponentId="{30F53DDF-44E8-462B-89E6-653F05B0CAE9}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qsvg.dll"/>
+    <ROW Component="qsvgicon.dll" ComponentId="{80C56CB6-1A06-4F30-BAEB-AE8709E3CBD5}" Directory_="iconengines_Dir" Attributes="256" KeyPath="qsvgicon.dll"/>
+    <ROW Component="qt_ar.qm" ComponentId="{DBF71CDF-DD00-4532-BE3F-96C4276F62B7}" Directory_="translations_Dir" Attributes="0" KeyPath="qt_ar.qm" Type="0"/>
+    <ROW Component="qtga.dll" ComponentId="{DF67021D-215D-41CE-9DA1-B5D65B7F36E2}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qtga.dll"/>
+    <ROW Component="qtiff.dll" ComponentId="{CA7393E8-FE71-4F05-92E7-0D0E3DE012FF}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qtiff.dll"/>
+    <ROW Component="qwbmp.dll" ComponentId="{A259A5A2-97F0-4D79-8EE1-D1561FBD3FE7}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qwbmp.dll"/>
+    <ROW Component="qwebp.dll" ComponentId="{0B582E61-A904-4345-B142-23672557BA31}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qwebp.dll"/>
+    <ROW Component="qwindows.dll" ComponentId="{3598F511-7047-4F8E-964F-0C45AC3D8600}" Directory_="platforms_Dir" Attributes="256" KeyPath="qwindows.dll"/>
+    <ROW Component="qwindowsvistastyle.dll" ComponentId="{D150F607-7636-4AE2-A5C5-750BCE289A10}" Directory_="styles_Dir" Attributes="256" KeyPath="qwindowsvistastyle.dll"/>
+    <ROW Component="ucrtbase.dll" ComponentId="{421F85BD-C24B-4203-B54E-DDB085CFC5F3}" Directory_="APPDIR" Attributes="256" KeyPath="ucrtbase.dll"/>
+    <ROW Component="ucrtbase_clr0400.dll" ComponentId="{6CA3DA2B-ED20-4BF6-9BC3-6BBBD263A251}" Directory_="APPDIR" Attributes="256" KeyPath="ucrtbase_clr0400.dll"/>
+    <ROW Component="ucrtbase_enclave.dll" ComponentId="{66C195E7-1580-473A-8E82-3FF59B5F3443}" Directory_="APPDIR" Attributes="256" KeyPath="ucrtbase_enclave.dll"/>
+    <ROW Component="vcruntime140.dll" ComponentId="{D1BDCF75-19DF-44E1-944C-390C8181C3B2}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140.dll"/>
+    <ROW Component="vcruntime140_1.dll" ComponentId="{26370D46-FEF6-4A39-8839-EA605B95893C}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_1.dll"/>
+    <ROW Component="vcruntime140_1_clr0400.dll" ComponentId="{E503BA18-7EAB-420E-8718-9B0F7C333294}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_1_clr0400.dll"/>
+    <ROW Component="vcruntime140_clr0400.dll" ComponentId="{ADBCE34C-D6D9-4659-9054-3CABBEB50863}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_clr0400.dll"/>
+    <ROW Component="vcruntime140_threads.dll" ComponentId="{D87B4EF2-1305-465B-81DC-EF875768ED4D}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_threads.dll"/>
+    <ROW Component="vcruntime140_threadsd.dll" ComponentId="{6C55D010-3239-42EE-8BE2-753407CDB33E}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_threadsd.dll"/>
+    <ROW Component="za.exe" ComponentId="{C3FAEB07-4DE0-433D-8335-B8BFF7A7A843}" Directory_="x64_Dir" Attributes="256" KeyPath="za.exe"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiFeatsComponent">
+    <ROW Feature="MainFeature" Title="MainFeature" Description="Description" Display="1" Level="1" Directory_="APPDIR" Attributes="0"/>
+    <ATTRIBUTE name="CurrentFeature" value="MainFeature"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiFilesComponent">
+    <ROW File="za.exe" Component_="za.exe" FileName="7za.exe" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\3rdparty\7z\x64\7za.exe" SelfReg="false" DigSign="true"/>
+    <ROW File="qgenericbearer.dll" Component_="qgenericbearer.dll" FileName="QGENER~1.DLL|qgenericbearer.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\bearer\qgenericbearer.dll" SelfReg="false"/>
+    <ROW File="qsvgicon.dll" Component_="qsvgicon.dll" FileName="qsvgicon.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\iconengines\qsvgicon.dll" SelfReg="false"/>
+    <ROW File="qgif.dll" Component_="qgif.dll" FileName="qgif.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qgif.dll" SelfReg="false"/>
+    <ROW File="qgifd.dll" Component_="qgifd.dll" FileName="qgifd.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qgifd.dll" SelfReg="false"/>
+    <ROW File="qicns.dll" Component_="qicns.dll" FileName="qicns.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qicns.dll" SelfReg="false"/>
+    <ROW File="qico.dll" Component_="qico.dll" FileName="qico.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qico.dll" SelfReg="false"/>
+    <ROW File="qjpeg.dll" Component_="qjpeg.dll" FileName="qjpeg.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qjpeg.dll" SelfReg="false"/>
+    <ROW File="qjpegd.dll" Component_="qjpegd.dll" FileName="qjpegd.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qjpegd.dll" SelfReg="false"/>
+    <ROW File="qsvg.dll" Component_="qsvg.dll" FileName="qsvg.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qsvg.dll" SelfReg="false"/>
+    <ROW File="qtga.dll" Component_="qtga.dll" FileName="qtga.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qtga.dll" SelfReg="false"/>
+    <ROW File="qtiff.dll" Component_="qtiff.dll" FileName="qtiff.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qtiff.dll" SelfReg="false"/>
+    <ROW File="qwbmp.dll" Component_="qwbmp.dll" FileName="qwbmp.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qwbmp.dll" SelfReg="false"/>
+    <ROW File="qwebp.dll" Component_="qwebp.dll" FileName="qwebp.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qwebp.dll" SelfReg="false"/>
+    <ROW File="qwindows.dll" Component_="qwindows.dll" FileName="qwindows.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\platforms\qwindows.dll" SelfReg="false"/>
+    <ROW File="qsqlite.dll" Component_="qsqlite.dll" FileName="qsqlite.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlite.dll" SelfReg="false"/>
+    <ROW File="qsqlited.dll" Component_="qsqlited.dll" FileName="qsqlited.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlited.dll" SelfReg="false"/>
+    <ROW File="qsqlodbc.dll" Component_="qsqlodbc.dll" FileName="qsqlodbc.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlodbc.dll" SelfReg="false"/>
+    <ROW File="qsqlpsql.dll" Component_="qsqlpsql.dll" FileName="qsqlpsql.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlpsql.dll" SelfReg="false"/>
+    <ROW File="qwindowsvistastyle.dll" Component_="qwindowsvistastyle.dll" FileName="QWINDO~1.DLL|qwindowsvistastyle.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\styles\qwindowsvistastyle.dll" SelfReg="false"/>
+    <ROW File="qt_ar.qm" Component_="qt_ar.qm" FileName="qt_ar.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ar.qm" SelfReg="false"/>
+    <ROW File="qt_bg.qm" Component_="qt_ar.qm" FileName="qt_bg.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_bg.qm" SelfReg="false"/>
+    <ROW File="qt_ca.qm" Component_="qt_ar.qm" FileName="qt_ca.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ca.qm" SelfReg="false"/>
+    <ROW File="qt_cs.qm" Component_="qt_ar.qm" FileName="qt_cs.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_cs.qm" SelfReg="false"/>
+    <ROW File="qt_da.qm" Component_="qt_ar.qm" FileName="qt_da.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_da.qm" SelfReg="false"/>
+    <ROW File="qt_de.qm" Component_="qt_ar.qm" FileName="qt_de.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_de.qm" SelfReg="false"/>
+    <ROW File="qt_en.qm" Component_="qt_ar.qm" FileName="qt_en.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_en.qm" SelfReg="false"/>
+    <ROW File="qt_es.qm" Component_="qt_ar.qm" FileName="qt_es.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_es.qm" SelfReg="false"/>
+    <ROW File="qt_fa.qm" Component_="qt_ar.qm" FileName="qt_fa.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_fa.qm" SelfReg="false"/>
+    <ROW File="qt_fi.qm" Component_="qt_ar.qm" FileName="qt_fi.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_fi.qm" SelfReg="false"/>
+    <ROW File="qt_fr.qm" Component_="qt_ar.qm" FileName="qt_fr.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_fr.qm" SelfReg="false"/>
+    <ROW File="qt_gd.qm" Component_="qt_ar.qm" FileName="qt_gd.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_gd.qm" SelfReg="false"/>
+    <ROW File="qt_he.qm" Component_="qt_ar.qm" FileName="qt_he.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_he.qm" SelfReg="false"/>
+    <ROW File="qt_hu.qm" Component_="qt_ar.qm" FileName="qt_hu.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_hu.qm" SelfReg="false"/>
+    <ROW File="qt_it.qm" Component_="qt_ar.qm" FileName="qt_it.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_it.qm" SelfReg="false"/>
+    <ROW File="qt_ja.qm" Component_="qt_ar.qm" FileName="qt_ja.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ja.qm" SelfReg="false"/>
+    <ROW File="qt_ko.qm" Component_="qt_ar.qm" FileName="qt_ko.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ko.qm" SelfReg="false"/>
+    <ROW File="qt_lv.qm" Component_="qt_ar.qm" FileName="qt_lv.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_lv.qm" SelfReg="false"/>
+    <ROW File="qt_nl.qm" Component_="qt_ar.qm" FileName="qt_nl.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_nl.qm" SelfReg="false"/>
+    <ROW File="qt_pl.qm" Component_="qt_ar.qm" FileName="qt_pl.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_pl.qm" SelfReg="false"/>
+    <ROW File="qt_pt_BR.qm" Component_="qt_ar.qm" FileName="qt_pt_BR.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_pt_BR.qm" SelfReg="false"/>
+    <ROW File="qt_ru.qm" Component_="qt_ar.qm" FileName="qt_ru.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ru.qm" SelfReg="false"/>
+    <ROW File="qt_sk.qm" Component_="qt_ar.qm" FileName="qt_sk.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_sk.qm" SelfReg="false"/>
+    <ROW File="qt_uk.qm" Component_="qt_ar.qm" FileName="qt_uk.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_uk.qm" SelfReg="false"/>
+    <ROW File="qt_zh_CN.qm" Component_="qt_ar.qm" FileName="qt_zh_CN.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_zh_CN.qm" SelfReg="false"/>
+    <ROW File="qt_zh_TW.qm" Component_="qt_ar.qm" FileName="qt_zh_TW.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_zh_TW.qm" SelfReg="false"/>
+    <ROW File="D3Dcompiler_47.dll" Component_="D3Dcompiler_47.dll" FileName="D3DCOM~1.DLL|D3Dcompiler_47.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\D3Dcompiler_47.dll" SelfReg="false"/>
+    <ROW File="libEGL.dll" Component_="libEGL.dll" FileName="libEGL.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\libEGL.dll" SelfReg="false"/>
+    <ROW File="libGLESV2.dll" Component_="libGLESV2.dll" FileName="LIBGLE~1.DLL|libGLESV2.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\libGLESV2.dll" SelfReg="false"/>
+    <ROW File="msvcp140.dll" Component_="msvcp140.dll" FileName="msvcp140.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140.dll" SelfReg="false"/>
+    <ROW File="msvcp140_1.dll" Component_="msvcp140_1.dll" FileName="MSVCP1~1.DLL|msvcp140_1.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_1.dll" SelfReg="false"/>
+    <ROW File="msvcp140_2.dll" Component_="msvcp140_2.dll" FileName="MSVCP1~2.DLL|msvcp140_2.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_2.dll" SelfReg="false"/>
+    <ROW File="msvcp140_atomic_wait.dll" Component_="msvcp140_atomic_wait.dll" FileName="MSVCP1~3.DLL|msvcp140_atomic_wait.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_atomic_wait.dll" SelfReg="false"/>
+    <ROW File="msvcp140_clr0400.dll" Component_="msvcp140_clr0400.dll" FileName="MSVCP1~4.DLL|msvcp140_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_clr0400.dll" SelfReg="false"/>
+    <ROW File="msvcp140_codecvt_ids.dll" Component_="msvcp140_codecvt_ids.dll" FileName="MSVCP1~5.DLL|msvcp140_codecvt_ids.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_codecvt_ids.dll" SelfReg="false"/>
+    <ROW File="NIM.exe" Component_="NIM.exe" FileName="NIM.exe" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\NIM.exe" SelfReg="false" DigSign="true"/>
+    <ROW File="opengl32sw.dll" Component_="opengl32sw.dll" FileName="OPENGL~1.DLL|opengl32sw.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\opengl32sw.dll" SelfReg="false"/>
+    <ROW File="Qt5Core.dll" Component_="Qt5Core.dll" FileName="Qt5Core.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Core.dll" SelfReg="false"/>
+    <ROW File="Qt5Gui.dll" Component_="Qt5Gui.dll" FileName="Qt5Gui.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Gui.dll" SelfReg="false"/>
+    <ROW File="Qt5Network.dll" Component_="Qt5Network.dll" FileName="QT5NET~1.DLL|Qt5Network.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Network.dll" SelfReg="false"/>
+    <ROW File="Qt5Sql.dll" Component_="Qt5Sql.dll" FileName="Qt5Sql.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Sql.dll" SelfReg="false"/>
+    <ROW File="Qt5Svg.dll" Component_="Qt5Svg.dll" FileName="Qt5Svg.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Svg.dll" SelfReg="false"/>
+    <ROW File="Qt5Widgets.dll" Component_="Qt5Widgets.dll" FileName="QT5WID~1.DLL|Qt5Widgets.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Widgets.dll" SelfReg="false"/>
+    <ROW File="ucrtbase.dll" Component_="ucrtbase.dll" FileName="ucrtbase.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\ucrtbase.dll" SelfReg="false"/>
+    <ROW File="ucrtbase_clr0400.dll" Component_="ucrtbase_clr0400.dll" FileName="UCRTBA~1.DLL|ucrtbase_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\ucrtbase_clr0400.dll" SelfReg="false"/>
+    <ROW File="ucrtbase_enclave.dll" Component_="ucrtbase_enclave.dll" FileName="UCRTBA~2.DLL|ucrtbase_enclave.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\ucrtbase_enclave.dll" SelfReg="false"/>
+    <ROW File="vcruntime140.dll" Component_="vcruntime140.dll" FileName="VCRUNT~1.DLL|vcruntime140.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_1.dll" Component_="vcruntime140_1.dll" FileName="VCRUNT~2.DLL|vcruntime140_1.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_1.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_1_clr0400.dll" Component_="vcruntime140_1_clr0400.dll" FileName="VCRUNT~3.DLL|vcruntime140_1_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_1_clr0400.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_clr0400.dll" Component_="vcruntime140_clr0400.dll" FileName="VCRUNT~4.DLL|vcruntime140_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_clr0400.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_threads.dll" Component_="vcruntime140_threads.dll" FileName="VCRUNT~5.DLL|vcruntime140_threads.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_threads.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_threadsd.dll" Component_="vcruntime140_threadsd.dll" FileName="VCRUNT~6.DLL|vcruntime140_threadsd.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_threadsd.dll" SelfReg="false"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.BootstrapperUISequenceComponent">
+    <ROW Action="AI_BACKUP_AI_SETUPEXEPATH" Sequence="249"/>
+    <ROW Action="AI_RESTORE_AI_SETUPEXEPATH" Condition="AI_SETUPEXEPATH_ORIGINAL" Sequence="251"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.BuildComponent">
+    <ROW BuildKey="DefaultBuild" BuildName="DefaultBuild" BuildOrder="1" BuildType="0" PackageFolder="[|AI_AIP_FOLDER][|AI_AIP_FILENAME]-SetupFiles" PackageFileName="[|AI_AIP_FILENAME]" Languages="zh" InstallationType="4" CabsLocation="1" PackageType="1" FilesInsideExe="true" ExtractionFolder="[AppDataFolder][|Manufacturer]\[|ProductName] [|ProductVersion]\install" ExtUI="true" UseLargeSchema="true" UACExecutionLevel="2"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.DictionaryComponent">
+    <ROW Path="&lt;AI_DICTS&gt;ui.ail"/>
+    <ROW Path="&lt;AI_DICTS&gt;ui_zh.ail"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.FragmentComponent">
+    <ROW Fragment="CommonUI.aip" Path="&lt;AI_FRAGS&gt;CommonUI.aip"/>
+    <ROW Fragment="InstallDlg.aip" Path="&lt;AI_THEMES&gt;surface\fragments\InstallDlg.aip"/>
+    <ROW Fragment="LicenseAgreementDlg.aip" Path="&lt;AI_THEMES&gt;surface\fragments\LicenseAgreementDlg.aip"/>
+    <ROW Fragment="MaintenanceTypeDlg.aip" Path="&lt;AI_THEMES&gt;surface\fragments\MaintenanceTypeDlg.aip"/>
+    <ROW Fragment="MessageDlg.aip" Path="&lt;AI_THEMES&gt;surface\fragments\MessageDlg.aip"/>
+    <ROW Fragment="SequenceDialogs.aip" Path="&lt;AI_THEMES&gt;surface\fragments\SequenceDialogs.aip"/>
+    <ROW Fragment="Sequences.aip" Path="&lt;AI_FRAGS&gt;Sequences.aip"/>
+    <ROW Fragment="StaticUIStrings.aip" Path="&lt;AI_FRAGS&gt;StaticUIStrings.aip"/>
+    <ROW Fragment="Themes.aip" Path="&lt;AI_FRAGS&gt;Themes.aip"/>
+    <ROW Fragment="UI.aip" Path="&lt;AI_THEMES&gt;surface\fragments\UI.aip"/>
+    <ROW Fragment="Validation.aip" Path="&lt;AI_FRAGS&gt;Validation.aip"/>
+    <ROW Fragment="VerifyRemoveDlg.aip" Path="&lt;AI_THEMES&gt;surface\fragments\VerifyRemoveDlg.aip"/>
+    <ROW Fragment="VerifyRepairDlg.aip" Path="&lt;AI_THEMES&gt;surface\fragments\VerifyRepairDlg.aip"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiActionTextComponent">
+    <ROW Action="AI_DeleteLzma" Description="正在删除从档案中提取的文件" DescriptionLocId="ActionText.Description.AI_DeleteLzma" TemplateLocId="-"/>
+    <ROW Action="AI_DeleteRLzma" Description="正在删除从档案中提取的文件" DescriptionLocId="ActionText.Description.AI_DeleteLzma" TemplateLocId="-"/>
+    <ROW Action="AI_ExtractFiles" Description="正在从档案中提取文件" DescriptionLocId="ActionText.Description.AI_ExtractLzma" TemplateLocId="-"/>
+    <ROW Action="AI_ExtractLzma" Description="正在从档案中提取文件" DescriptionLocId="ActionText.Description.AI_ExtractLzma" TemplateLocId="-"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiAppSearchComponent">
+    <ROW Property="AI_SETUPEXEPATH" Signature_="AI_EXE_PATH_LM" Builds="DefaultBuild"/>
+    <ROW Property="AI_SETUPEXEPATH" Signature_="AI_EXE_PATH_CU" Builds="DefaultBuild"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiBinaryComponent">
+    <ROW Name="ExternalUICleaner.dll" SourcePath="&lt;AI_CUSTACTS&gt;ExternalUICleaner.dll"/>
+    <ROW Name="Prereq.dll" SourcePath="&lt;AI_CUSTACTS&gt;Prereq.dll"/>
+    <ROW Name="ShortcutFlags.dll" SourcePath="&lt;AI_CUSTACTS&gt;ShortcutFlags.dll"/>
+    <ROW Name="aicustact.dll" SourcePath="&lt;AI_CUSTACTS&gt;aicustact.dll"/>
+    <ROW Name="lzmaextractor.dll" SourcePath="&lt;AI_CUSTACTS&gt;lzmaextractor.dll"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiControlComponent">
+    <ROW Dialog_="CustomizeDlg" Control="CustomizeDlgDialogInitializer" Type="DialogInitializer" X="0" Y="0" Width="0" Height="0" Attributes="0" Order="-1" TextLocId="-" HelpLocId="-" ExtDataLocId="-"/>
+    <ROW Dialog_="MsiRMFilesInUse" Control="Cancel" Type="ImageButton" X="163" Y="359" Width="78" Height="28" Attributes="2" Text="[NextCancelButtons]" TextStyle="[MetroButtonText]" Order="100" MsiKey="MsiRMFilesInUse#Cancel" Options="1" ExtType="ImageButton" ExtData="[ButtonText_Cancel]" ExtDataLocId="-"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control="PatchWelcomeDlgDialogInitializer" Type="DialogInitializer" X="0" Y="0" Width="0" Height="0" Attributes="0" Order="-1" TextLocId="-" HelpLocId="-" ExtDataLocId="-"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control="ProductNameTitle" Type="Text" X="83" Y="21" Width="250" Height="80" Attributes="196611" Text="[ProductName]" TextStyle="[ProductNameForSplash]" Order="600" TextLocId="Metro.Control.Text.ProductName" MsiKey="PatchWelcomeDlg#ProductNameTitle"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control="CompanyText" Type="Text" X="83" Y="16" Width="249" Height="12" Attributes="196611" Text="[Manufacturer]" TextStyle="[ManufacturerFont]" Order="700" TextLocId="Metro.Control.Text.Manufacturer" MsiKey="PatchWelcomeDlg#CompanyText"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control="ProductLogo" Type="Bitmap" X="17" Y="5" Width="59" Height="63" Attributes="1" Text="[AppLogoIcon]" Order="800" MsiKey="PatchWelcomeDlg#ProductLogo"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="Cancel" Type="ImageButton" X="163" Y="359" Width="78" Height="28" Attributes="2" Text="[NextCancelButtons]" TextStyle="[MetroButtonText]" Order="100" MsiKey="VerifyReadyDlg#Cancel" Options="1" ExtType="ImageButton" ExtData="[ButtonText_Cancel]" ExtDataLocId="-"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="Back" Type="PushButton" X="5" Y="381" Width="11" Height="11" Attributes="2" Text="Control.Text.VerifyReadyDlg#Back" Help="返回|" Order="200" TextLocId="Control.Text.VerifyReadyDlg#Back" HelpLocId="Air.Control.Help#Back" MsiKey="VerifyReadyDlg#Back" Options="1" ExtType="PushButton" ExtData="[ButtonText_Back]" ExtDataLocId="-"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="ReadyToInstall" Type="Text" X="17" Y="105" Width="312" Height="19" Attributes="196611" Text="准备安装" TextStyle="[SubTitleStyle]" Order="400" TextLocId="Control.Text.VerifyReadyDlg#Title" MsiKey="VerifyReadyDlg#ReadyToInstall"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="Text_2" Type="Text" X="17" Y="139" Width="312" Height="100" Attributes="65539" Text="[Wizard]准备开始 [ProductName] 安装" Order="500" TextLocId="Control.Text.VerifyReadyDlg#Description" MsiKey="VerifyReadyDlg#Text_2"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="InstallButton" Type="ImageButton" X="251" Y="359" Width="78" Height="28" Attributes="3" Text="[metrobuttonimage]" TextStyle="[MetroButtonText]" Order="600" MsiKey="VerifyReadyDlg#InstallButton" ExtType="ImageButton" ExtData="安装" ExtDataLocId="Metro.Control.ExtData.VerifyReadyDlg#InstallButton"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="ProductNameTitle" Type="Text" X="83" Y="21" Width="250" Height="80" Attributes="196611" Text="[ProductName]" TextStyle="[ProductNameForSplash]" Order="700" TextLocId="Metro.Control.Text.ProductName" MsiKey="VerifyReadyDlg#ProductNameTitle"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="CompanyText" Type="Text" X="83" Y="16" Width="249" Height="12" Attributes="196611" Text="[Manufacturer]" TextStyle="[ManufacturerFont]" Order="800" TextLocId="Metro.Control.Text.Manufacturer" MsiKey="VerifyReadyDlg#CompanyText"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="ProductLogo" Type="Bitmap" X="17" Y="5" Width="59" Height="63" Attributes="1" Text="[AppLogoIcon]" Order="900" MsiKey="VerifyReadyDlg#ProductLogo"/>
+    <ROW Dialog_="VerifyReadyDlg" Control="AdvInst" Type="Text" X="5" Y="388" Width="74" Height="11" Attributes="196611" Text="Advanced Installer" TextStyle="[BrandingStyle]" Order="1000" MsiKey="VerifyReadyDlg#AdvInst"/>
+    <ROW Dialog_="VerifyRemoveDlg" Control="Cancel" Type="ImageButton" X="163" Y="359" Width="78" Height="28" Attributes="2" Text="[NextCancelButtons]" TextStyle="[MetroButtonText]" Order="100" MsiKey="VerifyRemoveDlg#Cancel" Options="1" ExtType="ImageButton" ExtData="[ButtonText_Cancel]" ExtDataLocId="-"/>
+    <ROW Dialog_="VerifyRepairDlg" Control="Bitmap" Type="Bitmap" X="0" Y="0" Width="345" Height="400" Attributes="1" Text="[Background]" Order="100" MsiKey="VerifyRepairDlg#Bitmap"/>
+    <ROW Dialog_="VerifyRepairDlg" Control="Cancel" Type="ImageButton" X="163" Y="359" Width="78" Height="28" Attributes="2" Text="[NextCancelButtons]" TextStyle="[MetroButtonText]" Order="200" MsiKey="VerifyRepairDlg#Cancel" Options="1" ExtType="ImageButton" ExtData="[ButtonText_Cancel]" ExtDataLocId="-"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiControlEventComponent">
+    <ROW Dialog_="InstallDlg" Control_="Next" Event="EndDialog" Argument="Return" Condition="AI_INSTALL" Ordering="301"/>
+    <ROW Dialog_="InstallDlg" Control_="YouMustAgreeCtrl" Event="NewDialog" Argument="LicenseAgreementDlg" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="301"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Back" Event="NewDialog" Argument="InstallDlg" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="1"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Next" Event="EndDialog" Argument="Return" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="101"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="ChangeButton" Event="NewDialog" Argument="CustomizeDlg" Condition="AI_MAINT" Ordering="501"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="RemoveButton" Event="NewDialog" Argument="VerifyRemoveDlg" Condition="AI_MAINT AND InstallMode=&quot;Remove&quot;" Ordering="699"/>
+    <ROW Dialog_="VerifyRemoveDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT AND InstallMode=&quot;Remove&quot;" Ordering="1"/>
+    <ROW Dialog_="VerifyRemoveDlg" Control_="Remove" Event="EndDialog" Argument="Return" Condition="AI_MAINT AND InstallMode=&quot;Remove&quot;" Ordering="199" Options="1"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="RepairButton" Event="NewDialog" Argument="VerifyRepairDlg" Condition="AI_MAINT AND InstallMode=&quot;Repair&quot;" Ordering="699"/>
+    <ROW Dialog_="VerifyRepairDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT AND InstallMode=&quot;Repair&quot;" Ordering="1"/>
+    <ROW Dialog_="VerifyRepairDlg" Control_="Repair" Event="EndDialog" Argument="Return" Condition="AI_MAINT AND InstallMode=&quot;Repair&quot;" Ordering="299" Options="1"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Modify" Event="EndDialog" Argument="Return" Condition="AI_MAINT" Ordering="101"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT" Ordering="1"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Next" Event="EndDialog" Argument="Return" Condition="AI_PATCH" Ordering="201"/>
+    <ROW Dialog_="ResumeDlg" Control_="Install" Event="EndDialog" Argument="Return" Condition="AI_RESUME" Ordering="99"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Next" Event="SpawnDialog" Argument="OutOfRbDiskDlg" Condition="AI_INSTALL AND AiLicenseAgreement AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND (PROMPTROLLBACKCOST=&quot;P&quot; OR NOT PROMPTROLLBACKCOST)" Ordering="102" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Next" Event="EnableRollback" Argument="False" Condition="AI_INSTALL AND AiLicenseAgreement AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND PROMPTROLLBACKCOST=&quot;D&quot;" Ordering="103" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Next" Event="SpawnDialog" Argument="OutOfDiskDlg" Condition="AI_INSTALL AND AiLicenseAgreement AND ( (OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 1) OR (OutOfDiskSpace = 1 AND PROMPTROLLBACKCOST=&quot;F&quot;) )" Ordering="104" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="LicenseAgreementDlgDialogInitializer" Event="[AI_ButtonText_Next_Orig]" Argument="[ButtonText_Next]" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="101" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="LicenseAgreementDlgDialogInitializer" Event="[ButtonText_Next]" Argument="[[AI_CommitButton]]" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="102" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="LicenseAgreementDlgDialogInitializer" Event="[AI_Text_Next_Orig]" Argument="[Text_Next]" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="103" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="LicenseAgreementDlgDialogInitializer" Event="[Text_Next]" Argument="[Text_Install]" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="104" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Back" Event="[ButtonText_Next]" Argument="[AI_ButtonText_Next_Orig]" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="2" Options="2"/>
+    <ROW Dialog_="LicenseAgreementDlg" Control_="Back" Event="[Text_Next]" Argument="[AI_Text_Next_Orig]" Condition="AI_INSTALL AND AiLicenseAgreement" Ordering="3" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="Next" Event="SpawnDialog" Argument="OutOfRbDiskDlg" Condition="AI_INSTALL AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND (PROMPTROLLBACKCOST=&quot;P&quot; OR NOT PROMPTROLLBACKCOST)" Ordering="302" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="Next" Event="EnableRollback" Argument="False" Condition="AI_INSTALL AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND PROMPTROLLBACKCOST=&quot;D&quot;" Ordering="303" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="Next" Event="SpawnDialog" Argument="OutOfDiskDlg" Condition="AI_INSTALL AND ( (OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 1) OR (OutOfDiskSpace = 1 AND PROMPTROLLBACKCOST=&quot;F&quot;) )" Ordering="304" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="InstallDlgDialogInitializer" Event="[AI_ButtonText_Next_Orig]" Argument="[ButtonText_Next]" Condition="AI_INSTALL" Ordering="101" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="InstallDlgDialogInitializer" Event="[ButtonText_Next]" Argument="[[AI_CommitButton]]" Condition="AI_INSTALL" Ordering="102" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="InstallDlgDialogInitializer" Event="[AI_Text_Next_Orig]" Argument="[Text_Next]" Condition="AI_INSTALL" Ordering="103" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="InstallDlgDialogInitializer" Event="[Text_Next]" Argument="[Text_Install]" Condition="AI_INSTALL" Ordering="104" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="YouMustAgreeCtrl" Event="[ButtonText_Next]" Argument="[AI_ButtonText_Next_Orig]" Condition="AI_INSTALL" Ordering="302" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="YouMustAgreeCtrl" Event="[Text_Next]" Argument="[AI_Text_Next_Orig]" Condition="AI_INSTALL" Ordering="303" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="Back" Event="[ButtonText_Next]" Argument="[AI_ButtonText_Next_Orig]" Condition="AI_INSTALL" Ordering="0" Options="2"/>
+    <ROW Dialog_="InstallDlg" Control_="Back" Event="[Text_Next]" Argument="[AI_Text_Next_Orig]" Condition="AI_INSTALL" Ordering="1" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Modify" Event="SpawnDialog" Argument="OutOfRbDiskDlg" Condition="AI_MAINT AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND (PROMPTROLLBACKCOST=&quot;P&quot; OR NOT PROMPTROLLBACKCOST)" Ordering="102" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Modify" Event="EnableRollback" Argument="False" Condition="AI_MAINT AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND PROMPTROLLBACKCOST=&quot;D&quot;" Ordering="103" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Modify" Event="SpawnDialog" Argument="OutOfDiskDlg" Condition="AI_MAINT AND ( (OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 1) OR (OutOfDiskSpace = 1 AND PROMPTROLLBACKCOST=&quot;F&quot;) )" Ordering="104" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="CustomizeDlgDialogInitializer" Event="[AI_ButtonText_Next_Orig]" Argument="[ButtonText_Next]" Condition="AI_MAINT" Ordering="0" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="CustomizeDlgDialogInitializer" Event="[ButtonText_Next]" Argument="[[AI_CommitButton]]" Condition="AI_MAINT" Ordering="1" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="CustomizeDlgDialogInitializer" Event="[AI_Text_Next_Orig]" Argument="[Text_Next]" Condition="AI_MAINT" Ordering="2" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="CustomizeDlgDialogInitializer" Event="[Text_Next]" Argument="[Text_Install]" Condition="AI_MAINT" Ordering="3" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Back" Event="[ButtonText_Next]" Argument="[AI_ButtonText_Next_Orig]" Condition="AI_MAINT" Ordering="2" Options="2"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Back" Event="[Text_Next]" Argument="[AI_Text_Next_Orig]" Condition="AI_MAINT" Ordering="3" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Next" Event="SpawnDialog" Argument="OutOfRbDiskDlg" Condition="AI_PATCH AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND (PROMPTROLLBACKCOST=&quot;P&quot; OR NOT PROMPTROLLBACKCOST)" Ordering="202" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Next" Event="EnableRollback" Argument="False" Condition="AI_PATCH AND OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 0 AND PROMPTROLLBACKCOST=&quot;D&quot;" Ordering="203" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Next" Event="SpawnDialog" Argument="OutOfDiskDlg" Condition="AI_PATCH AND ( (OutOfDiskSpace = 1 AND OutOfNoRbDiskSpace = 1) OR (OutOfDiskSpace = 1 AND PROMPTROLLBACKCOST=&quot;F&quot;) )" Ordering="204" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="PatchWelcomeDlgDialogInitializer" Event="[AI_ButtonText_Next_Orig]" Argument="[ButtonText_Next]" Condition="AI_PATCH" Ordering="0" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="PatchWelcomeDlgDialogInitializer" Event="[ButtonText_Next]" Argument="[[AI_CommitButton]]" Condition="AI_PATCH" Ordering="1" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="PatchWelcomeDlgDialogInitializer" Event="[AI_Text_Next_Orig]" Argument="[Text_Next]" Condition="AI_PATCH" Ordering="2" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="PatchWelcomeDlgDialogInitializer" Event="[Text_Next]" Argument="[Text_Install]" Condition="AI_PATCH" Ordering="3" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Back" Event="[ButtonText_Next]" Argument="[AI_ButtonText_Next_Orig]" Condition="AI_PATCH" Ordering="0" Options="2"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Back" Event="[Text_Next]" Argument="[AI_Text_Next_Orig]" Condition="AI_PATCH" Ordering="1" Options="2"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiCreateFolderComponent">
+    <ROW Directory_="APPDIR" Component_="APPDIR" ManualDelete="true"/>
+    <ROW Directory_="SHORTCUTDIR" Component_="SHORTCUTDIR" ManualDelete="false"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiCustActComponent">
+    <ROW Action="AI_ApplyShortcutFlags" Type="3073" Source="ShortcutFlags.dll" Target="UpdateShortcutFlags" WithoutSeq="true"/>
+    <ROW Action="AI_BACKUP_AI_SETUPEXEPATH" Type="51" Source="AI_SETUPEXEPATH_ORIGINAL" Target="[AI_SETUPEXEPATH]"/>
+    <ROW Action="AI_ChooseTextStyles" Type="1" Source="aicustact.dll" Target="ChooseTextStyles"/>
+    <ROW Action="AI_DATA_SETTER" Type="51" Source="CustomActionData" Target="[AI_SETUPEXEPATH]"/>
+    <ROW Action="AI_DOWNGRADE" Type="19" Target="4010"/>
+    <ROW Action="AI_DeleteCadLzma" Type="51" Source="AI_DeleteLzma" Target="[AI_SETUPEXEPATH]"/>
+    <ROW Action="AI_DeleteLzma" Type="1025" Source="lzmaextractor.dll" Target="DeleteLZMAFiles"/>
+    <ROW Action="AI_DeleteRCadLzma" Type="51" Source="AI_DeleteRLzma" Target="[AI_SETUPEXEPATH]"/>
+    <ROW Action="AI_DeleteRLzma" Type="1281" Source="lzmaextractor.dll" Target="DeleteLZMAFiles"/>
+    <ROW Action="AI_DoRemoveExternalUIStub" Type="3585" Source="ExternalUICleaner.dll" Target="DoRemoveExternalUIStub" WithoutSeq="true"/>
+    <ROW Action="AI_DpiContentScale" Type="1" Source="aicustact.dll" Target="DpiContentScale"/>
+    <ROW Action="AI_EnableDebugLog" Type="321" Source="aicustact.dll" Target="EnableDebugLog"/>
+    <ROW Action="AI_ExtractCadLzma" Type="51" Source="AI_ExtractLzma" Target="[AI_SETUPEXEPATH]"/>
+    <ROW Action="AI_ExtractFiles" Type="1" Source="Prereq.dll" Target="ExtractSourceFiles" AdditionalSeq="AI_DATA_SETTER"/>
+    <ROW Action="AI_ExtractLzma" Type="1025" Source="lzmaextractor.dll" Target="ExtractLZMAFiles"/>
+    <ROW Action="AI_FindExeLzma" Type="1" Source="lzmaextractor.dll" Target="FindEXE"/>
+    <ROW Action="AI_GetArpIconPath" Type="1" Source="aicustact.dll" Target="GetArpIconPath"/>
+    <ROW Action="AI_InstallModeCheck" Type="1" Source="aicustact.dll" Target="UpdateInstallMode" WithoutSeq="true"/>
+    <ROW Action="AI_PREPARE_UPGRADE" Type="65" Source="aicustact.dll" Target="PrepareUpgrade"/>
+    <ROW Action="AI_PRESERVE_INSTALL_TYPE" Type="65" Source="aicustact.dll" Target="PreserveInstallType"/>
+    <ROW Action="AI_PinShortcuts" Type="1" Source="ShortcutFlags.dll" Target="PinShortcuts"/>
+    <ROW Action="AI_PinToStartScreen" Type="1025" Source="ShortcutFlags.dll" Target="PinToStartScreen" WithoutSeq="true"/>
+    <ROW Action="AI_PinToTaskbar" Type="1025" Source="ShortcutFlags.dll" Target="PinToTaskbar" WithoutSeq="true"/>
+    <ROW Action="AI_PrepareShortcutFlags" Type="1" Source="ShortcutFlags.dll" Target="PrepareActionData"/>
+    <ROW Action="AI_RESTORE_AI_SETUPEXEPATH" Type="51" Source="AI_SETUPEXEPATH" Target="[AI_SETUPEXEPATH_ORIGINAL]"/>
+    <ROW Action="AI_RESTORE_LOCATION" Type="65" Source="aicustact.dll" Target="RestoreLocation"/>
+    <ROW Action="AI_RemoveExternalUIStub" Type="1" Source="ExternalUICleaner.dll" Target="RemoveExternalUIStub"/>
+    <ROW Action="AI_ResolveKnownFolders" Type="1" Source="aicustact.dll" Target="AI_ResolveKnownFolders"/>
+    <ROW Action="AI_RunFinishActions" Type="1" Source="aicustact.dll" Target="RunFinishActions" WithoutSeq="true"/>
+    <ROW Action="AI_SHOW_LOG" Type="65" Source="aicustact.dll" Target="LaunchLogFile" WithoutSeq="true"/>
+    <ROW Action="AI_STORE_LOCATION" Type="51" Source="ARPINSTALLLOCATION" Target="[APPDIR]"/>
+    <ROW Action="AI_UnpinFromStartScreen" Type="1025" Source="ShortcutFlags.dll" Target="UnpinFromStartScreen" WithoutSeq="true"/>
+    <ROW Action="AI_UnpinFromTaskbar" Type="1025" Source="ShortcutFlags.dll" Target="UnpinFromTaskbar" WithoutSeq="true"/>
+    <ROW Action="AI_UnpinShortcuts" Type="1" Source="ShortcutFlags.dll" Target="UnpinShortcuts"/>
+    <ROW Action="SET_APPDIR" Type="307" Source="APPDIR" Target="[ProgramFilesFolder][Manufacturer]\[ProductName]"/>
+    <ROW Action="SET_SHORTCUTDIR" Type="307" Source="SHORTCUTDIR" Target="[ProgramMenuFolder][ProductName]"/>
+    <ROW Action="SET_TARGETDIR_TO_APPDIR" Type="51" Source="TARGETDIR" Target="[APPDIR]"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiFeatCompsComponent">
+    <ROW Feature_="MainFeature" Component_="APPDIR"/>
+    <ROW Feature_="MainFeature" Component_="ProductInformation"/>
+    <ROW Feature_="MainFeature" Component_="za.exe"/>
+    <ROW Feature_="MainFeature" Component_="qgenericbearer.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsvgicon.dll"/>
+    <ROW Feature_="MainFeature" Component_="qgif.dll"/>
+    <ROW Feature_="MainFeature" Component_="qgifd.dll"/>
+    <ROW Feature_="MainFeature" Component_="qicns.dll"/>
+    <ROW Feature_="MainFeature" Component_="qico.dll"/>
+    <ROW Feature_="MainFeature" Component_="qjpeg.dll"/>
+    <ROW Feature_="MainFeature" Component_="qjpegd.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsvg.dll"/>
+    <ROW Feature_="MainFeature" Component_="qtga.dll"/>
+    <ROW Feature_="MainFeature" Component_="qtiff.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwbmp.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwebp.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwindows.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlite.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlited.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlodbc.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlpsql.dll"/>
+    <ROW Feature_="MainFeature" Component_="D3Dcompiler_47.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwindowsvistastyle.dll"/>
+    <ROW Feature_="MainFeature" Component_="qt_ar.qm"/>
+    <ROW Feature_="MainFeature" Component_="libEGL.dll"/>
+    <ROW Feature_="MainFeature" Component_="libGLESV2.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_1.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_2.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_atomic_wait.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_codecvt_ids.dll"/>
+    <ROW Feature_="MainFeature" Component_="NIM.exe"/>
+    <ROW Feature_="MainFeature" Component_="opengl32sw.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Core.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Gui.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Network.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Sql.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Svg.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Widgets.dll"/>
+    <ROW Feature_="MainFeature" Component_="ucrtbase.dll"/>
+    <ROW Feature_="MainFeature" Component_="ucrtbase_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="ucrtbase_enclave.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_1.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_1_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_threads.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_threadsd.dll"/>
+    <ROW Feature_="MainFeature" Component_="AI_ExePath"/>
+    <ROW Feature_="MainFeature" Component_="AI_CustomARPName"/>
+    <ROW Feature_="MainFeature" Component_="SHORTCUTDIR"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiIconsComponent">
+    <ROW Name="app.exe" SourcePath="res\icons\app.ico" Index="0"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiInstExSeqComponent">
+    <ROW Action="AI_DOWNGRADE" Condition="AI_NEWERPRODUCTFOUND AND (UILevel &lt;&gt; 5)" Sequence="210"/>
+    <ROW Action="AI_RESTORE_LOCATION" Condition="APPDIR=&quot;&quot;" Sequence="749"/>
+    <ROW Action="AI_STORE_LOCATION" Condition="(Not Installed) OR REINSTALL" Sequence="1502"/>
+    <ROW Action="AI_PREPARE_UPGRADE" Condition="AI_UPGRADE=&quot;No&quot; AND (Not Installed)" Sequence="1397"/>
+    <ROW Action="AI_ResolveKnownFolders" Sequence="52"/>
+    <ROW Action="AI_EnableDebugLog" Sequence="51"/>
+    <ROW Action="AI_BACKUP_AI_SETUPEXEPATH" Sequence="99" Builds="DefaultBuild"/>
+    <ROW Action="AI_RESTORE_AI_SETUPEXEPATH" Condition="AI_SETUPEXEPATH_ORIGINAL" Sequence="101" Builds="DefaultBuild"/>
+    <ROW Action="AI_DeleteCadLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="199" Builds="DefaultBuild"/>
+    <ROW Action="AI_DeleteRCadLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="198" Builds="DefaultBuild"/>
+    <ROW Action="AI_ExtractCadLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="197" Builds="DefaultBuild"/>
+    <ROW Action="AI_FindExeLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="196" Builds="DefaultBuild"/>
+    <ROW Action="AI_ExtractLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="1549" Builds="DefaultBuild"/>
+    <ROW Action="AI_DeleteRLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="1548" Builds="DefaultBuild"/>
+    <ROW Action="AI_DeleteLzma" Condition="SETUPEXEDIR=&quot;&quot; AND Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;) AND (NOT PATCH)" Sequence="6599" Builds="DefaultBuild"/>
+    <ROW Action="AI_ExtractFiles" Sequence="1399" Builds="DefaultBuild"/>
+    <ROW Action="AI_DATA_SETTER" Sequence="1398"/>
+    <ROW Action="AI_GetArpIconPath" Sequence="1401"/>
+    <ROW Action="AI_RemoveExternalUIStub" Condition="(REMOVE=&quot;ALL&quot;) AND ((VersionNT &gt; 500) OR((VersionNT = 500) AND (ServicePackLevel &gt;= 4)))" Sequence="1501"/>
+    <ROW Action="AI_PrepareShortcutFlags" Condition="(VersionNT &gt; 501) AND ((NOT Installed) OR (Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;)))" Sequence="4501"/>
+    <ROW Action="AI_PinShortcuts" Condition="(VersionNT &gt; 600) AND ((NOT Installed) OR (Installed AND (REMOVE&lt;&gt;&quot;ALL&quot;) AND (AI_INSTALL_MODE&lt;&gt;&quot;Remove&quot;)))" Sequence="6499"/>
+    <ROW Action="AI_UnpinShortcuts" Condition="(VersionNT &gt; 600) AND (REMOVE = &quot;ALL&quot;)" Sequence="3199"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiInstallUISequenceComponent">
+    <ROW Action="AI_PRESERVE_INSTALL_TYPE" Sequence="199"/>
+    <ROW Action="AI_RESTORE_LOCATION" Condition="APPDIR=&quot;&quot;" Sequence="749"/>
+    <ROW Action="AI_ResolveKnownFolders" Sequence="53"/>
+    <ROW Action="AI_DpiContentScale" Sequence="52"/>
+    <ROW Action="AI_EnableDebugLog" Sequence="51"/>
+    <ROW Action="InstallDlg" Condition="AI_INSTALL" Sequence="1230" SeqType="3" MsiKey="WelcomeDlg"/>
+    <ROW Action="MaintenanceTypeDlg" Condition="AI_MAINT" Sequence="1250" SeqType="3" MsiKey="MaintenanceTypeDlg"/>
+    <ROW Action="AI_BACKUP_AI_SETUPEXEPATH" Sequence="99"/>
+    <ROW Action="AI_RESTORE_AI_SETUPEXEPATH" Condition="AI_SETUPEXEPATH_ORIGINAL" Sequence="101"/>
+    <ROW Action="AI_ChooseTextStyles" Sequence="74"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiLaunchConditionsComponent">
+    <ROW Condition="((VersionNT &lt;&gt; 501) AND (VersionNT &lt;&gt; 502))" Description="[ProductName] 无法安装在 [WindowsTypeNT5XDisplay]." DescriptionLocId="AI.LaunchCondition.NoNT5X" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="(VersionNT &lt;&gt; 400)" Description="[ProductName] 无法安装在 [WindowsTypeNT40Display]." DescriptionLocId="AI.LaunchCondition.NoNT40" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="(VersionNT &lt;&gt; 500)" Description="[ProductName] 无法安装在 [WindowsTypeNT50Display]." DescriptionLocId="AI.LaunchCondition.NoNT50" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="(VersionNT &lt;&gt; 600)" Description="[ProductName] 无法安装在 [WindowsTypeNT60Display]." DescriptionLocId="AI.LaunchCondition.NoNT60" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="SETUPEXEDIR OR (REMOVE=&quot;ALL&quot;)" Description="此安装包只能从引导程序运行。" DescriptionLocId="AI.LaunchCondition.RequireBootstrapper" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="VersionNT" Description="[ProductName] 无法安装在 [WindowsType9XDisplay]." DescriptionLocId="AI.LaunchCondition.No9X" IsPredefined="true" Builds="DefaultBuild"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiRegLocatorComponent">
+    <ROW Signature_="AI_EXE_PATH_CU" Root="1" Key="Software\Caphyon\Advanced Installer\LZMA\[ProductCode]\[ProductVersion]" Name="AI_ExePath" Type="2"/>
+    <ROW Signature_="AI_EXE_PATH_LM" Root="2" Key="Software\Caphyon\Advanced Installer\LZMA\[ProductCode]\[ProductVersion]" Name="AI_ExePath" Type="2"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiRegsComponent">
+    <ROW Registry="AI_ExePath" Root="-1" Key="Software\Caphyon\Advanced Installer\LZMA\[ProductCode]\[ProductVersion]" Name="AI_ExePath" Value="[AI_SETUPEXEPATH]" Component_="AI_ExePath"/>
+    <ROW Registry="AdvancedInstaller" Root="-1" Key="Software\Caphyon\Advanced Installer" Name="\"/>
+    <ROW Registry="Caphyon" Root="-1" Key="Software\Caphyon" Name="\"/>
+    <ROW Registry="Comments" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="Comments" Value="[ARPCOMMENTS]" Component_="AI_CustomARPName"/>
+    <ROW Registry="Contact" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="Contact" Value="[ARPCONTACT]" Component_="AI_CustomARPName"/>
+    <ROW Registry="CurrentVersion" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion" Name="\"/>
+    <ROW Registry="DisplayIcon" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="DisplayIcon" Value="[ARP_ICON_PATH]" Component_="AI_CustomARPName"/>
+    <ROW Registry="DisplayName" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="DisplayName" Value="[AI_PRODUCTNAME_ARP]" Component_="AI_CustomARPName"/>
+    <ROW Registry="DisplayVersion" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="DisplayVersion" Value="[ProductVersion]" Component_="AI_CustomARPName"/>
+    <ROW Registry="EstimatedSize" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="EstimatedSize" Value="#[AI_ARP_SIZE]" Component_="AI_CustomARPName" VirtualValue="#"/>
+    <ROW Registry="HelpLink" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="HelpLink" Value="[ARPHELPLINK]" Component_="AI_CustomARPName"/>
+    <ROW Registry="HelpTelephone" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="HelpTelephone" Value="[ARPHELPTELEPHONE]" Component_="AI_CustomARPName"/>
+    <ROW Registry="InstallLocation" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="InstallLocation" Value="[APPDIR]" Component_="AI_CustomARPName"/>
+    <ROW Registry="LZMA" Root="-1" Key="Software\Caphyon\Advanced Installer\LZMA" Name="\"/>
+    <ROW Registry="Manufacturer" Root="-1" Key="Software\[Manufacturer]" Name="\"/>
+    <ROW Registry="Microsoft" Root="-1" Key="Software\Microsoft" Name="\"/>
+    <ROW Registry="ModifyPath" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="ModifyPath" Value="[AI_UNINSTALLER] /i [ProductCode] AI_UNINSTALLER_CTP=1" Component_="AI_CustomARPName"/>
+    <ROW Registry="NoRepair" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="NoRepair" Value="#1" Component_="AI_CustomARPName" VirtualValue="#"/>
+    <ROW Registry="Path" Root="-1" Key="Software\[Manufacturer]\[ProductName]" Name="Path" Value="[APPDIR]" Component_="ProductInformation"/>
+    <ROW Registry="ProductCode" Root="-1" Key="Software\Caphyon\Advanced Installer\LZMA\[ProductCode]" Name="\"/>
+    <ROW Registry="ProductName" Root="-1" Key="Software\[Manufacturer]\[ProductName]" Name="\"/>
+    <ROW Registry="ProductNameProductVersion" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="\"/>
+    <ROW Registry="ProductVersion" Root="-1" Key="Software\Caphyon\Advanced Installer\LZMA\[ProductCode]\[ProductVersion]" Name="\"/>
+    <ROW Registry="Publisher" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="Publisher" Value="[Manufacturer]" Component_="AI_CustomARPName"/>
+    <ROW Registry="Readme" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="Readme" Value="[ARPREADME]" Component_="AI_CustomARPName"/>
+    <ROW Registry="Software" Root="-1" Key="Software" Name="\"/>
+    <ROW Registry="URLInfoAbout" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="URLInfoAbout" Value="[ARPURLINFOABOUT]" Component_="AI_CustomARPName"/>
+    <ROW Registry="URLUpdateInfo" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="URLUpdateInfo" Value="[ARPURLUPDATEINFO]" Component_="AI_CustomARPName"/>
+    <ROW Registry="Uninstall" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall" Name="\"/>
+    <ROW Registry="UninstallPath" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="UninstallPath" Value="[AI_UNINSTALLER] /x [ProductCode] AI_UNINSTALLER_CTP=1" Component_="AI_CustomARPName"/>
+    <ROW Registry="UninstallString" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="UninstallString" Value="[AI_UNINSTALLER] /x [ProductCode] AI_UNINSTALLER_CTP=1" Component_="AI_CustomARPName"/>
+    <ROW Registry="Version" Root="-1" Key="Software\[Manufacturer]\[ProductName]" Name="Version" Value="[ProductVersion]" Component_="ProductInformation"/>
+    <ROW Registry="VersionMajor" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="VersionMajor" Value="#1" Component_="AI_CustomARPName" VirtualValue="#"/>
+    <ROW Registry="VersionMinor" Root="-1" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[ProductName] [ProductVersion]" Name="VersionMinor" Value="#0" Component_="AI_CustomARPName" VirtualValue="#"/>
+    <ROW Registry="Windows" Root="-1" Key="Software\Microsoft\Windows" Name="\"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiShortsComponent">
+    <ROW Shortcut="NIM" Directory_="SHORTCUTDIR" Name="NIM" Component_="NIM.exe" Target="[#NIM.exe]" Hotkey="0" Icon_="app.exe" IconIndex="0" ShowCmd="1" WkDir="APPDIR" CustomFlags="1" Advertised="true"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiTextStyleComponent">
+    <ROW TextStyle="BrandingStyle" FaceName="Segoe UI" Size="8" Color="2171376" MultiBuildColor="DefaultBuild:2565927" StyleBits="0" MsiKey="BrandingStyle"/>
+    <ROW TextStyle="InputCtrlFont" FaceName="Segoe UI" Size="8" Color="16777215" MultiBuildColor="DefaultBuild:14079702" StyleBits="0" MsiKey="InputCtrlFont"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiThemeComponent">
+    <ATTRIBUTE name="UsedTheme" value="surface"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiUpgradeComponent">
+    <ROW UpgradeCode="[|UpgradeCode]" VersionMin="0.0.1" VersionMax="[|ProductVersion]" Attributes="257" ActionProperty="OLDPRODUCTS"/>
+    <ROW UpgradeCode="[|UpgradeCode]" VersionMin="[|ProductVersion]" Attributes="2" ActionProperty="AI_NEWERPRODUCTFOUND"/>
+  </COMPONENT>
+</DOCUMENT>

+ 326 - 0
Client/NIM.back(23.8).aip

@@ -0,0 +1,326 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<DOCUMENT Type="Advanced Installer" CreateVersion="23.8" version="23.8" Modules="simple" RootPath="." Language="zh" Id="{A6B93378-6C66-425C-86A4-FFC00F034A86}">
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiPropsComponent">
+    <ROW Property="AI_BITMAP_DISPLAY_MODE" Value="0"/>
+    <ROW Property="ALLUSERS" Value="1"/>
+    <ROW Property="ARPCOMMENTS" Value="此 Installer 数据库包含了安装 [|ProductName] 所需的逻辑和数据。" ValueLocId="*"/>
+    <ROW Property="Manufacturer" Value="NIM"/>
+    <ROW Property="ProductCode" Value="2052:{301E1BD3-81F4-41C2-B761-ADCFBE42D684} " Type="16"/>
+    <ROW Property="ProductLanguage" Value="1033"/>
+    <ROW Property="ProductName" Value="NIM"/>
+    <ROW Property="ProductVersion" Value="1.0.0" Options="32"/>
+    <ROW Property="SecureCustomProperties" Value="OLDPRODUCTS;AI_NEWERPRODUCTFOUND"/>
+    <ROW Property="UpgradeCode" Value="{51BDF350-8DE6-4E99-82DF-C18E41DC3A55}"/>
+    <ROW Property="WindowsType9X" MultiBuildValue="DefaultBuild:Windows 9x/ME" ValueLocId="-"/>
+    <ROW Property="WindowsType9XDisplay" MultiBuildValue="DefaultBuild:Windows 9x/ME" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT40" MultiBuildValue="DefaultBuild:Windows NT 4.0" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT40Display" MultiBuildValue="DefaultBuild:Windows NT 4.0" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT50" MultiBuildValue="DefaultBuild:Windows 2000" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT50Display" MultiBuildValue="DefaultBuild:Windows 2000" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT5X" MultiBuildValue="DefaultBuild:Windows XP/2003" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT5XDisplay" MultiBuildValue="DefaultBuild:Windows XP/2003" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT60" MultiBuildValue="DefaultBuild:Windows Vista/Server 2008" ValueLocId="-"/>
+    <ROW Property="WindowsTypeNT60Display" MultiBuildValue="DefaultBuild:Windows Vista/Server 2008" ValueLocId="-"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiDirsComponent">
+    <ROW Directory="APPDIR" Directory_Parent="TARGETDIR" DefaultDir="APPDIR:." IsPseudoRoot="1" DirectoryOptions="3"/>
+    <ROW Directory="TARGETDIR" DefaultDir="SourceDir"/>
+    <ROW Directory="bearer_Dir" Directory_Parent="APPDIR" DefaultDir="bearer" DirectoryOptions="3"/>
+    <ROW Directory="iconengines_Dir" Directory_Parent="APPDIR" DefaultDir="ICONEN~1|iconengines" DirectoryOptions="3"/>
+    <ROW Directory="imageformats_Dir" Directory_Parent="APPDIR" DefaultDir="IMAGEF~1|imageformats" DirectoryOptions="3"/>
+    <ROW Directory="platforms_Dir" Directory_Parent="APPDIR" DefaultDir="PLATFO~1|platforms" DirectoryOptions="3"/>
+    <ROW Directory="rdparty_Dir" Directory_Parent="APPDIR" DefaultDir="3rdparty" DirectoryOptions="3"/>
+    <ROW Directory="sqldrivers_Dir" Directory_Parent="APPDIR" DefaultDir="SQLDRI~1|sqldrivers" DirectoryOptions="3"/>
+    <ROW Directory="styles_Dir" Directory_Parent="APPDIR" DefaultDir="styles" DirectoryOptions="3"/>
+    <ROW Directory="translations_Dir" Directory_Parent="APPDIR" DefaultDir="TRANSL~1|translations" DirectoryOptions="3"/>
+    <ROW Directory="x64_Dir" Directory_Parent="z_Dir" DefaultDir="x64" DirectoryOptions="3"/>
+    <ROW Directory="z_Dir" Directory_Parent="rdparty_Dir" DefaultDir="7z" DirectoryOptions="3"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.SideBySideGuidComponent">
+    <ROW Component="APPDIR" Value="{17EB4A42-2A9A-4678-8913-45C3FED5114C}"/>
+    <ROW Component="ProductInformation" Value="{A2129477-7040-4D1C-ACCD-1BDC2D26D619}"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiCompsComponent">
+    <ROW Component="APPDIR" ComponentId="{17EB4A42-2A9A-4678-8913-45C3FED5114C}" Directory_="APPDIR" Attributes="0"/>
+    <ROW Component="D3Dcompiler_47.dll" ComponentId="{5BAF1A44-F31B-4A04-B43E-E36EC232E41A}" Directory_="APPDIR" Attributes="256" KeyPath="D3Dcompiler_47.dll"/>
+    <ROW Component="NIM.exe" ComponentId="{2A252122-DDF6-45FD-A675-F792B79F7DBB}" Directory_="APPDIR" Attributes="256" KeyPath="NIM.exe"/>
+    <ROW Component="ProductInformation" ComponentId="{A2129477-7040-4D1C-ACCD-1BDC2D26D619}" Directory_="APPDIR" Attributes="4" KeyPath="Version"/>
+    <ROW Component="Qt5Core.dll" ComponentId="{3DE8D5D4-40FB-4351-BE3C-E466B4609985}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Core.dll"/>
+    <ROW Component="Qt5Gui.dll" ComponentId="{6BA7A9A8-165F-4484-87A6-9DFB355EC135}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Gui.dll"/>
+    <ROW Component="Qt5Network.dll" ComponentId="{E8B0BB9D-9BE4-4F93-9ED8-77DA30C23025}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Network.dll"/>
+    <ROW Component="Qt5Sql.dll" ComponentId="{AE778057-7818-4D84-82D2-6E9B6398453C}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Sql.dll"/>
+    <ROW Component="Qt5Svg.dll" ComponentId="{265CC092-BAD1-43FE-8F16-AFB948E12ED9}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Svg.dll"/>
+    <ROW Component="Qt5Widgets.dll" ComponentId="{8BF8C8F6-5314-48DF-8269-F14BA2C9BADA}" Directory_="APPDIR" Attributes="256" KeyPath="Qt5Widgets.dll"/>
+    <ROW Component="libEGL.dll" ComponentId="{87F9872B-96E3-4187-A4BB-C4D107F37450}" Directory_="APPDIR" Attributes="256" KeyPath="libEGL.dll"/>
+    <ROW Component="libGLESV2.dll" ComponentId="{9480B67A-5356-48FD-A8A6-811FAE40010B}" Directory_="APPDIR" Attributes="256" KeyPath="libGLESV2.dll"/>
+    <ROW Component="msvcp140.dll" ComponentId="{8E5F3529-6D6A-407E-BC79-62417BFD9564}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140.dll"/>
+    <ROW Component="msvcp140_1.dll" ComponentId="{FE8EF5B1-E029-48CE-929F-DC3DC4A8D683}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_1.dll"/>
+    <ROW Component="msvcp140_2.dll" ComponentId="{1028346E-8F61-4055-B61B-1970EEFF5EDE}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_2.dll"/>
+    <ROW Component="msvcp140_atomic_wait.dll" ComponentId="{32F154F2-E48F-41BC-B3F5-6256DAF9AE03}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_atomic_wait.dll"/>
+    <ROW Component="msvcp140_clr0400.dll" ComponentId="{615635D5-9B1F-416D-8DDB-A478970D9ADA}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_clr0400.dll"/>
+    <ROW Component="msvcp140_codecvt_ids.dll" ComponentId="{6E80FBDB-477C-4AAD-B2A7-C0A7B2A53E77}" Directory_="APPDIR" Attributes="256" KeyPath="msvcp140_codecvt_ids.dll"/>
+    <ROW Component="opengl32sw.dll" ComponentId="{F26C7E7D-65C8-45DD-8E5B-2E56B1F1D150}" Directory_="APPDIR" Attributes="256" KeyPath="opengl32sw.dll"/>
+    <ROW Component="qgenericbearer.dll" ComponentId="{262D9BD1-DC24-4071-8801-B12A5F72DAC1}" Directory_="bearer_Dir" Attributes="256" KeyPath="qgenericbearer.dll"/>
+    <ROW Component="qgif.dll" ComponentId="{AA785284-81A8-41F2-A583-1F48109DBEEF}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qgif.dll"/>
+    <ROW Component="qgifd.dll" ComponentId="{D76D3C5C-FB93-444F-A276-9B6FCEBA38DB}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qgifd.dll"/>
+    <ROW Component="qicns.dll" ComponentId="{39419932-14CF-44AA-A76C-5B0CD658D956}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qicns.dll"/>
+    <ROW Component="qico.dll" ComponentId="{A50D622B-6657-485C-9093-83F7239C5BEB}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qico.dll"/>
+    <ROW Component="qjpeg.dll" ComponentId="{92099A72-E110-4C47-ABD7-16974630839A}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qjpeg.dll"/>
+    <ROW Component="qjpegd.dll" ComponentId="{15D13389-144B-47ED-A7D5-A6F74A6323AF}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qjpegd.dll"/>
+    <ROW Component="qsqlite.dll" ComponentId="{857E1F9A-3C16-49BA-A7BA-C1FA605E5804}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlite.dll"/>
+    <ROW Component="qsqlited.dll" ComponentId="{CB9CBEB3-592A-4C0F-9C7F-8D694D3324DD}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlited.dll"/>
+    <ROW Component="qsqlodbc.dll" ComponentId="{2FE1763F-C5C8-4BA4-860F-ACEF05FA938D}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlodbc.dll"/>
+    <ROW Component="qsqlpsql.dll" ComponentId="{0F26C215-9C07-423E-B6B4-D0415E9F0A2B}" Directory_="sqldrivers_Dir" Attributes="256" KeyPath="qsqlpsql.dll"/>
+    <ROW Component="qsvg.dll" ComponentId="{30F53DDF-44E8-462B-89E6-653F05B0CAE9}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qsvg.dll"/>
+    <ROW Component="qsvgicon.dll" ComponentId="{80C56CB6-1A06-4F30-BAEB-AE8709E3CBD5}" Directory_="iconengines_Dir" Attributes="256" KeyPath="qsvgicon.dll"/>
+    <ROW Component="qt_ar.qm" ComponentId="{DBF71CDF-DD00-4532-BE3F-96C4276F62B7}" Directory_="translations_Dir" Attributes="0" KeyPath="qt_ar.qm" Type="0"/>
+    <ROW Component="qtga.dll" ComponentId="{DF67021D-215D-41CE-9DA1-B5D65B7F36E2}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qtga.dll"/>
+    <ROW Component="qtiff.dll" ComponentId="{CA7393E8-FE71-4F05-92E7-0D0E3DE012FF}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qtiff.dll"/>
+    <ROW Component="qwbmp.dll" ComponentId="{A259A5A2-97F0-4D79-8EE1-D1561FBD3FE7}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qwbmp.dll"/>
+    <ROW Component="qwebp.dll" ComponentId="{0B582E61-A904-4345-B142-23672557BA31}" Directory_="imageformats_Dir" Attributes="256" KeyPath="qwebp.dll"/>
+    <ROW Component="qwindows.dll" ComponentId="{3598F511-7047-4F8E-964F-0C45AC3D8600}" Directory_="platforms_Dir" Attributes="256" KeyPath="qwindows.dll"/>
+    <ROW Component="qwindowsvistastyle.dll" ComponentId="{D150F607-7636-4AE2-A5C5-750BCE289A10}" Directory_="styles_Dir" Attributes="256" KeyPath="qwindowsvistastyle.dll"/>
+    <ROW Component="ucrtbase.dll" ComponentId="{421F85BD-C24B-4203-B54E-DDB085CFC5F3}" Directory_="APPDIR" Attributes="256" KeyPath="ucrtbase.dll"/>
+    <ROW Component="ucrtbase_clr0400.dll" ComponentId="{6CA3DA2B-ED20-4BF6-9BC3-6BBBD263A251}" Directory_="APPDIR" Attributes="256" KeyPath="ucrtbase_clr0400.dll"/>
+    <ROW Component="ucrtbase_enclave.dll" ComponentId="{66C195E7-1580-473A-8E82-3FF59B5F3443}" Directory_="APPDIR" Attributes="256" KeyPath="ucrtbase_enclave.dll"/>
+    <ROW Component="vcruntime140.dll" ComponentId="{D1BDCF75-19DF-44E1-944C-390C8181C3B2}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140.dll"/>
+    <ROW Component="vcruntime140_1.dll" ComponentId="{26370D46-FEF6-4A39-8839-EA605B95893C}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_1.dll"/>
+    <ROW Component="vcruntime140_1_clr0400.dll" ComponentId="{E503BA18-7EAB-420E-8718-9B0F7C333294}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_1_clr0400.dll"/>
+    <ROW Component="vcruntime140_clr0400.dll" ComponentId="{ADBCE34C-D6D9-4659-9054-3CABBEB50863}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_clr0400.dll"/>
+    <ROW Component="vcruntime140_threads.dll" ComponentId="{D87B4EF2-1305-465B-81DC-EF875768ED4D}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_threads.dll"/>
+    <ROW Component="vcruntime140_threadsd.dll" ComponentId="{6C55D010-3239-42EE-8BE2-753407CDB33E}" Directory_="APPDIR" Attributes="256" KeyPath="vcruntime140_threadsd.dll"/>
+    <ROW Component="za.exe" ComponentId="{C3FAEB07-4DE0-433D-8335-B8BFF7A7A843}" Directory_="x64_Dir" Attributes="256" KeyPath="za.exe"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiFeatsComponent">
+    <ROW Feature="MainFeature" Title="MainFeature" Description="Description" Display="1" Level="1" Directory_="APPDIR" Attributes="0"/>
+    <ATTRIBUTE name="CurrentFeature" value="MainFeature"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiFilesComponent">
+    <ROW File="za.exe" Component_="za.exe" FileName="7za.exe" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\3rdparty\7z\x64\7za.exe" SelfReg="false" DigSign="true"/>
+    <ROW File="qgenericbearer.dll" Component_="qgenericbearer.dll" FileName="QGENER~1.DLL|qgenericbearer.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\bearer\qgenericbearer.dll" SelfReg="false"/>
+    <ROW File="qsvgicon.dll" Component_="qsvgicon.dll" FileName="qsvgicon.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\iconengines\qsvgicon.dll" SelfReg="false"/>
+    <ROW File="qgif.dll" Component_="qgif.dll" FileName="qgif.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qgif.dll" SelfReg="false"/>
+    <ROW File="qgifd.dll" Component_="qgifd.dll" FileName="qgifd.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qgifd.dll" SelfReg="false"/>
+    <ROW File="qicns.dll" Component_="qicns.dll" FileName="qicns.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qicns.dll" SelfReg="false"/>
+    <ROW File="qico.dll" Component_="qico.dll" FileName="qico.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qico.dll" SelfReg="false"/>
+    <ROW File="qjpeg.dll" Component_="qjpeg.dll" FileName="qjpeg.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qjpeg.dll" SelfReg="false"/>
+    <ROW File="qjpegd.dll" Component_="qjpegd.dll" FileName="qjpegd.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qjpegd.dll" SelfReg="false"/>
+    <ROW File="qsvg.dll" Component_="qsvg.dll" FileName="qsvg.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qsvg.dll" SelfReg="false"/>
+    <ROW File="qtga.dll" Component_="qtga.dll" FileName="qtga.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qtga.dll" SelfReg="false"/>
+    <ROW File="qtiff.dll" Component_="qtiff.dll" FileName="qtiff.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qtiff.dll" SelfReg="false"/>
+    <ROW File="qwbmp.dll" Component_="qwbmp.dll" FileName="qwbmp.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qwbmp.dll" SelfReg="false"/>
+    <ROW File="qwebp.dll" Component_="qwebp.dll" FileName="qwebp.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\imageformats\qwebp.dll" SelfReg="false"/>
+    <ROW File="qwindows.dll" Component_="qwindows.dll" FileName="qwindows.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\platforms\qwindows.dll" SelfReg="false"/>
+    <ROW File="qsqlite.dll" Component_="qsqlite.dll" FileName="qsqlite.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlite.dll" SelfReg="false"/>
+    <ROW File="qsqlited.dll" Component_="qsqlited.dll" FileName="qsqlited.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlited.dll" SelfReg="false"/>
+    <ROW File="qsqlodbc.dll" Component_="qsqlodbc.dll" FileName="qsqlodbc.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlodbc.dll" SelfReg="false"/>
+    <ROW File="qsqlpsql.dll" Component_="qsqlpsql.dll" FileName="qsqlpsql.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\sqldrivers\qsqlpsql.dll" SelfReg="false"/>
+    <ROW File="qwindowsvistastyle.dll" Component_="qwindowsvistastyle.dll" FileName="QWINDO~1.DLL|qwindowsvistastyle.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\styles\qwindowsvistastyle.dll" SelfReg="false"/>
+    <ROW File="qt_ar.qm" Component_="qt_ar.qm" FileName="qt_ar.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ar.qm" SelfReg="false"/>
+    <ROW File="qt_bg.qm" Component_="qt_ar.qm" FileName="qt_bg.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_bg.qm" SelfReg="false"/>
+    <ROW File="qt_ca.qm" Component_="qt_ar.qm" FileName="qt_ca.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ca.qm" SelfReg="false"/>
+    <ROW File="qt_cs.qm" Component_="qt_ar.qm" FileName="qt_cs.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_cs.qm" SelfReg="false"/>
+    <ROW File="qt_da.qm" Component_="qt_ar.qm" FileName="qt_da.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_da.qm" SelfReg="false"/>
+    <ROW File="qt_de.qm" Component_="qt_ar.qm" FileName="qt_de.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_de.qm" SelfReg="false"/>
+    <ROW File="qt_en.qm" Component_="qt_ar.qm" FileName="qt_en.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_en.qm" SelfReg="false"/>
+    <ROW File="qt_es.qm" Component_="qt_ar.qm" FileName="qt_es.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_es.qm" SelfReg="false"/>
+    <ROW File="qt_fa.qm" Component_="qt_ar.qm" FileName="qt_fa.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_fa.qm" SelfReg="false"/>
+    <ROW File="qt_fi.qm" Component_="qt_ar.qm" FileName="qt_fi.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_fi.qm" SelfReg="false"/>
+    <ROW File="qt_fr.qm" Component_="qt_ar.qm" FileName="qt_fr.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_fr.qm" SelfReg="false"/>
+    <ROW File="qt_gd.qm" Component_="qt_ar.qm" FileName="qt_gd.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_gd.qm" SelfReg="false"/>
+    <ROW File="qt_he.qm" Component_="qt_ar.qm" FileName="qt_he.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_he.qm" SelfReg="false"/>
+    <ROW File="qt_hu.qm" Component_="qt_ar.qm" FileName="qt_hu.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_hu.qm" SelfReg="false"/>
+    <ROW File="qt_it.qm" Component_="qt_ar.qm" FileName="qt_it.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_it.qm" SelfReg="false"/>
+    <ROW File="qt_ja.qm" Component_="qt_ar.qm" FileName="qt_ja.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ja.qm" SelfReg="false"/>
+    <ROW File="qt_ko.qm" Component_="qt_ar.qm" FileName="qt_ko.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ko.qm" SelfReg="false"/>
+    <ROW File="qt_lv.qm" Component_="qt_ar.qm" FileName="qt_lv.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_lv.qm" SelfReg="false"/>
+    <ROW File="qt_nl.qm" Component_="qt_ar.qm" FileName="qt_nl.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_nl.qm" SelfReg="false"/>
+    <ROW File="qt_pl.qm" Component_="qt_ar.qm" FileName="qt_pl.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_pl.qm" SelfReg="false"/>
+    <ROW File="qt_pt_BR.qm" Component_="qt_ar.qm" FileName="qt_pt_BR.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_pt_BR.qm" SelfReg="false"/>
+    <ROW File="qt_ru.qm" Component_="qt_ar.qm" FileName="qt_ru.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_ru.qm" SelfReg="false"/>
+    <ROW File="qt_sk.qm" Component_="qt_ar.qm" FileName="qt_sk.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_sk.qm" SelfReg="false"/>
+    <ROW File="qt_uk.qm" Component_="qt_ar.qm" FileName="qt_uk.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_uk.qm" SelfReg="false"/>
+    <ROW File="qt_zh_CN.qm" Component_="qt_ar.qm" FileName="qt_zh_CN.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_zh_CN.qm" SelfReg="false"/>
+    <ROW File="qt_zh_TW.qm" Component_="qt_ar.qm" FileName="qt_zh_TW.qm" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\translations\qt_zh_TW.qm" SelfReg="false"/>
+    <ROW File="D3Dcompiler_47.dll" Component_="D3Dcompiler_47.dll" FileName="D3DCOM~1.DLL|D3Dcompiler_47.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\D3Dcompiler_47.dll" SelfReg="false"/>
+    <ROW File="libEGL.dll" Component_="libEGL.dll" FileName="libEGL.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\libEGL.dll" SelfReg="false"/>
+    <ROW File="libGLESV2.dll" Component_="libGLESV2.dll" FileName="LIBGLE~1.DLL|libGLESV2.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\libGLESV2.dll" SelfReg="false"/>
+    <ROW File="msvcp140.dll" Component_="msvcp140.dll" FileName="msvcp140.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140.dll" SelfReg="false"/>
+    <ROW File="msvcp140_1.dll" Component_="msvcp140_1.dll" FileName="MSVCP1~1.DLL|msvcp140_1.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_1.dll" SelfReg="false"/>
+    <ROW File="msvcp140_2.dll" Component_="msvcp140_2.dll" FileName="MSVCP1~2.DLL|msvcp140_2.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_2.dll" SelfReg="false"/>
+    <ROW File="msvcp140_atomic_wait.dll" Component_="msvcp140_atomic_wait.dll" FileName="MSVCP1~3.DLL|msvcp140_atomic_wait.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_atomic_wait.dll" SelfReg="false"/>
+    <ROW File="msvcp140_clr0400.dll" Component_="msvcp140_clr0400.dll" FileName="MSVCP1~4.DLL|msvcp140_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_clr0400.dll" SelfReg="false"/>
+    <ROW File="msvcp140_codecvt_ids.dll" Component_="msvcp140_codecvt_ids.dll" FileName="MSVCP1~5.DLL|msvcp140_codecvt_ids.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\msvcp140_codecvt_ids.dll" SelfReg="false"/>
+    <ROW File="NIM.exe" Component_="NIM.exe" FileName="NIM.exe" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\NIM.exe" SelfReg="false" DigSign="true"/>
+    <ROW File="opengl32sw.dll" Component_="opengl32sw.dll" FileName="OPENGL~1.DLL|opengl32sw.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\opengl32sw.dll" SelfReg="false"/>
+    <ROW File="Qt5Core.dll" Component_="Qt5Core.dll" FileName="Qt5Core.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Core.dll" SelfReg="false"/>
+    <ROW File="Qt5Gui.dll" Component_="Qt5Gui.dll" FileName="Qt5Gui.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Gui.dll" SelfReg="false"/>
+    <ROW File="Qt5Network.dll" Component_="Qt5Network.dll" FileName="QT5NET~1.DLL|Qt5Network.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Network.dll" SelfReg="false"/>
+    <ROW File="Qt5Sql.dll" Component_="Qt5Sql.dll" FileName="Qt5Sql.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Sql.dll" SelfReg="false"/>
+    <ROW File="Qt5Svg.dll" Component_="Qt5Svg.dll" FileName="Qt5Svg.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Svg.dll" SelfReg="false"/>
+    <ROW File="Qt5Widgets.dll" Component_="Qt5Widgets.dll" FileName="QT5WID~1.DLL|Qt5Widgets.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\Qt5Widgets.dll" SelfReg="false"/>
+    <ROW File="ucrtbase.dll" Component_="ucrtbase.dll" FileName="ucrtbase.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\ucrtbase.dll" SelfReg="false"/>
+    <ROW File="ucrtbase_clr0400.dll" Component_="ucrtbase_clr0400.dll" FileName="UCRTBA~1.DLL|ucrtbase_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\ucrtbase_clr0400.dll" SelfReg="false"/>
+    <ROW File="ucrtbase_enclave.dll" Component_="ucrtbase_enclave.dll" FileName="UCRTBA~2.DLL|ucrtbase_enclave.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\ucrtbase_enclave.dll" SelfReg="false"/>
+    <ROW File="vcruntime140.dll" Component_="vcruntime140.dll" FileName="VCRUNT~1.DLL|vcruntime140.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_1.dll" Component_="vcruntime140_1.dll" FileName="VCRUNT~2.DLL|vcruntime140_1.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_1.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_1_clr0400.dll" Component_="vcruntime140_1_clr0400.dll" FileName="VCRUNT~3.DLL|vcruntime140_1_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_1_clr0400.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_clr0400.dll" Component_="vcruntime140_clr0400.dll" FileName="VCRUNT~4.DLL|vcruntime140_clr0400.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_clr0400.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_threads.dll" Component_="vcruntime140_threads.dll" FileName="VCRUNT~5.DLL|vcruntime140_threads.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_threads.dll" SelfReg="false"/>
+    <ROW File="vcruntime140_threadsd.dll" Component_="vcruntime140_threadsd.dll" FileName="VCRUNT~6.DLL|vcruntime140_threadsd.dll" Version="65535.65535.65535.65535" Attributes="0" SourcePath="bin\Release\vcruntime140_threadsd.dll" SelfReg="false"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.BuildComponent">
+    <ROW BuildKey="DefaultBuild" BuildName="DefaultBuild" BuildOrder="1" BuildType="0" PackageFolder="[|AI_AIP_FOLDER][|AI_AIP_FILENAME]-SetupFiles" PackageFileName="[|AI_AIP_FILENAME]" Languages="zh" InstallationType="4" UseLargeSchema="true"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.DictionaryComponent">
+    <ROW Path="&lt;AI_DICTS&gt;ui.ail"/>
+    <ROW Path="&lt;AI_DICTS&gt;ui_zh.ail"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.FragmentComponent">
+    <ROW Fragment="CommonUI.aip" Path="&lt;AI_FRAGS&gt;CommonUI.aip"/>
+    <ROW Fragment="FolderDlg.aip" Path="&lt;AI_THEMES&gt;classic\fragments\FolderDlg.aip"/>
+    <ROW Fragment="MaintenanceTypeDlg.aip" Path="&lt;AI_THEMES&gt;classic\fragments\MaintenanceTypeDlg.aip"/>
+    <ROW Fragment="MaintenanceWelcomeDlg.aip" Path="&lt;AI_THEMES&gt;classic\fragments\MaintenanceWelcomeDlg.aip"/>
+    <ROW Fragment="SequenceDialogs.aip" Path="&lt;AI_THEMES&gt;classic\fragments\SequenceDialogs.aip"/>
+    <ROW Fragment="Sequences.aip" Path="&lt;AI_FRAGS&gt;Sequences.aip"/>
+    <ROW Fragment="StaticUIStrings.aip" Path="&lt;AI_FRAGS&gt;StaticUIStrings.aip"/>
+    <ROW Fragment="Themes.aip" Path="&lt;AI_FRAGS&gt;Themes.aip"/>
+    <ROW Fragment="UI.aip" Path="&lt;AI_THEMES&gt;classic\fragments\UI.aip"/>
+    <ROW Fragment="Validation.aip" Path="&lt;AI_FRAGS&gt;Validation.aip"/>
+    <ROW Fragment="VerifyRemoveDlg.aip" Path="&lt;AI_THEMES&gt;classic\fragments\VerifyRemoveDlg.aip"/>
+    <ROW Fragment="VerifyRepairDlg.aip" Path="&lt;AI_THEMES&gt;classic\fragments\VerifyRepairDlg.aip"/>
+    <ROW Fragment="WelcomeDlg.aip" Path="&lt;AI_THEMES&gt;classic\fragments\WelcomeDlg.aip"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiBinaryComponent">
+    <ROW Name="aicustact.dll" SourcePath="&lt;AI_CUSTACTS&gt;aicustact.dll"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiControlEventComponent">
+    <ROW Dialog_="WelcomeDlg" Control_="Next" Event="NewDialog" Argument="FolderDlg" Condition="AI_INSTALL" Ordering="1"/>
+    <ROW Dialog_="FolderDlg" Control_="Next" Event="NewDialog" Argument="VerifyReadyDlg" Condition="AI_INSTALL" Ordering="201"/>
+    <ROW Dialog_="FolderDlg" Control_="Back" Event="NewDialog" Argument="WelcomeDlg" Condition="AI_INSTALL" Ordering="1"/>
+    <ROW Dialog_="MaintenanceWelcomeDlg" Control_="Next" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT" Ordering="99"/>
+    <ROW Dialog_="VerifyReadyDlg" Control_="Install" Event="EndDialog" Argument="Return" Condition="AI_MAINT" Ordering="198"/>
+    <ROW Dialog_="VerifyReadyDlg" Control_="Back" Event="NewDialog" Argument="CustomizeDlg" Condition="AI_MAINT" Ordering="202"/>
+    <ROW Dialog_="VerifyReadyDlg" Control_="Install" Event="EndDialog" Argument="Return" Condition="AI_INSTALL" Ordering="197"/>
+    <ROW Dialog_="VerifyReadyDlg" Control_="Back" Event="NewDialog" Argument="FolderDlg" Condition="AI_INSTALL" Ordering="201"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Next" Event="NewDialog" Argument="VerifyReadyDlg" Condition="AI_MAINT" Ordering="101"/>
+    <ROW Dialog_="CustomizeDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT" Ordering="1"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="ChangeButton" Event="NewDialog" Argument="CustomizeDlg" Condition="AI_MAINT" Ordering="501"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceWelcomeDlg" Condition="AI_MAINT" Ordering="1"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="RemoveButton" Event="NewDialog" Argument="VerifyRemoveDlg" Condition="AI_MAINT AND InstallMode=&quot;Remove&quot;" Ordering="601"/>
+    <ROW Dialog_="VerifyRemoveDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT AND InstallMode=&quot;Remove&quot;" Ordering="1"/>
+    <ROW Dialog_="MaintenanceTypeDlg" Control_="RepairButton" Event="NewDialog" Argument="VerifyRepairDlg" Condition="AI_MAINT AND InstallMode=&quot;Repair&quot;" Ordering="601"/>
+    <ROW Dialog_="VerifyRepairDlg" Control_="Back" Event="NewDialog" Argument="MaintenanceTypeDlg" Condition="AI_MAINT AND InstallMode=&quot;Repair&quot;" Ordering="1"/>
+    <ROW Dialog_="VerifyRepairDlg" Control_="Repair" Event="EndDialog" Argument="Return" Condition="AI_MAINT AND InstallMode=&quot;Repair&quot;" Ordering="399" Options="1"/>
+    <ROW Dialog_="VerifyRemoveDlg" Control_="Remove" Event="EndDialog" Argument="Return" Condition="AI_MAINT AND InstallMode=&quot;Remove&quot;" Ordering="299" Options="1"/>
+    <ROW Dialog_="PatchWelcomeDlg" Control_="Next" Event="NewDialog" Argument="VerifyReadyDlg" Condition="AI_PATCH" Ordering="201"/>
+    <ROW Dialog_="ResumeDlg" Control_="Install" Event="EndDialog" Argument="Return" Condition="AI_RESUME" Ordering="299"/>
+    <ROW Dialog_="VerifyReadyDlg" Control_="Install" Event="EndDialog" Argument="Return" Condition="AI_PATCH" Ordering="199"/>
+    <ROW Dialog_="VerifyReadyDlg" Control_="Back" Event="NewDialog" Argument="PatchWelcomeDlg" Condition="AI_PATCH" Ordering="203"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiCreateFolderComponent">
+    <ROW Directory_="APPDIR" Component_="APPDIR" ManualDelete="true"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiCustActComponent">
+    <ROW Action="AI_DOWNGRADE" Type="19" Target="4010"/>
+    <ROW Action="AI_DpiContentScale" Type="1" Source="aicustact.dll" Target="DpiContentScale"/>
+    <ROW Action="AI_EnableDebugLog" Type="321" Source="aicustact.dll" Target="EnableDebugLog"/>
+    <ROW Action="AI_InstallModeCheck" Type="1" Source="aicustact.dll" Target="UpdateInstallMode" WithoutSeq="true"/>
+    <ROW Action="AI_PREPARE_UPGRADE" Type="65" Source="aicustact.dll" Target="PrepareUpgrade"/>
+    <ROW Action="AI_PRESERVE_INSTALL_TYPE" Type="65" Source="aicustact.dll" Target="PreserveInstallType"/>
+    <ROW Action="AI_RESTORE_LOCATION" Type="65" Source="aicustact.dll" Target="RestoreLocation"/>
+    <ROW Action="AI_ResolveKnownFolders" Type="1" Source="aicustact.dll" Target="AI_ResolveKnownFolders"/>
+    <ROW Action="AI_SHOW_LOG" Type="65" Source="aicustact.dll" Target="LaunchLogFile" WithoutSeq="true"/>
+    <ROW Action="AI_STORE_LOCATION" Type="51" Source="ARPINSTALLLOCATION" Target="[APPDIR]"/>
+    <ROW Action="SET_APPDIR" Type="307" Source="APPDIR" Target="[ProgramFilesFolder][Manufacturer]\[ProductName]"/>
+    <ROW Action="SET_SHORTCUTDIR" Type="307" Source="SHORTCUTDIR" Target="[ProgramMenuFolder][ProductName]"/>
+    <ROW Action="SET_TARGETDIR_TO_APPDIR" Type="51" Source="TARGETDIR" Target="[APPDIR]"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiFeatCompsComponent">
+    <ROW Feature_="MainFeature" Component_="APPDIR"/>
+    <ROW Feature_="MainFeature" Component_="ProductInformation"/>
+    <ROW Feature_="MainFeature" Component_="za.exe"/>
+    <ROW Feature_="MainFeature" Component_="qgenericbearer.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsvgicon.dll"/>
+    <ROW Feature_="MainFeature" Component_="qgif.dll"/>
+    <ROW Feature_="MainFeature" Component_="qgifd.dll"/>
+    <ROW Feature_="MainFeature" Component_="qicns.dll"/>
+    <ROW Feature_="MainFeature" Component_="qico.dll"/>
+    <ROW Feature_="MainFeature" Component_="qjpeg.dll"/>
+    <ROW Feature_="MainFeature" Component_="qjpegd.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsvg.dll"/>
+    <ROW Feature_="MainFeature" Component_="qtga.dll"/>
+    <ROW Feature_="MainFeature" Component_="qtiff.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwbmp.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwebp.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwindows.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlite.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlited.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlodbc.dll"/>
+    <ROW Feature_="MainFeature" Component_="qsqlpsql.dll"/>
+    <ROW Feature_="MainFeature" Component_="D3Dcompiler_47.dll"/>
+    <ROW Feature_="MainFeature" Component_="qwindowsvistastyle.dll"/>
+    <ROW Feature_="MainFeature" Component_="qt_ar.qm"/>
+    <ROW Feature_="MainFeature" Component_="libEGL.dll"/>
+    <ROW Feature_="MainFeature" Component_="libGLESV2.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_1.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_2.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_atomic_wait.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="msvcp140_codecvt_ids.dll"/>
+    <ROW Feature_="MainFeature" Component_="NIM.exe"/>
+    <ROW Feature_="MainFeature" Component_="opengl32sw.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Core.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Gui.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Network.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Sql.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Svg.dll"/>
+    <ROW Feature_="MainFeature" Component_="Qt5Widgets.dll"/>
+    <ROW Feature_="MainFeature" Component_="ucrtbase.dll"/>
+    <ROW Feature_="MainFeature" Component_="ucrtbase_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="ucrtbase_enclave.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_1.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_1_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_clr0400.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_threads.dll"/>
+    <ROW Feature_="MainFeature" Component_="vcruntime140_threadsd.dll"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiInstExSeqComponent">
+    <ROW Action="AI_DOWNGRADE" Condition="AI_NEWERPRODUCTFOUND AND (UILevel &lt;&gt; 5)" Sequence="210"/>
+    <ROW Action="AI_RESTORE_LOCATION" Condition="APPDIR=&quot;&quot;" Sequence="749"/>
+    <ROW Action="AI_STORE_LOCATION" Condition="(Not Installed) OR REINSTALL" Sequence="1501"/>
+    <ROW Action="AI_PREPARE_UPGRADE" Condition="AI_UPGRADE=&quot;No&quot; AND (Not Installed)" Sequence="1399"/>
+    <ROW Action="AI_ResolveKnownFolders" Sequence="52"/>
+    <ROW Action="AI_EnableDebugLog" Sequence="51"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiInstallUISequenceComponent">
+    <ROW Action="AI_PRESERVE_INSTALL_TYPE" Sequence="199"/>
+    <ROW Action="AI_RESTORE_LOCATION" Condition="APPDIR=&quot;&quot;" Sequence="749"/>
+    <ROW Action="AI_ResolveKnownFolders" Sequence="53"/>
+    <ROW Action="AI_DpiContentScale" Sequence="52"/>
+    <ROW Action="AI_EnableDebugLog" Sequence="51"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiLaunchConditionsComponent">
+    <ROW Condition="((VersionNT &lt;&gt; 501) AND (VersionNT &lt;&gt; 502))" Description="[ProductName] 无法安装在 [WindowsTypeNT5XDisplay]." DescriptionLocId="AI.LaunchCondition.NoNT5X" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="(VersionNT &lt;&gt; 400)" Description="[ProductName] 无法安装在 [WindowsTypeNT40Display]." DescriptionLocId="AI.LaunchCondition.NoNT40" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="(VersionNT &lt;&gt; 500)" Description="[ProductName] 无法安装在 [WindowsTypeNT50Display]." DescriptionLocId="AI.LaunchCondition.NoNT50" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="(VersionNT &lt;&gt; 600)" Description="[ProductName] 无法安装在 [WindowsTypeNT60Display]." DescriptionLocId="AI.LaunchCondition.NoNT60" IsPredefined="true" Builds="DefaultBuild"/>
+    <ROW Condition="VersionNT" Description="[ProductName] 无法安装在 [WindowsType9XDisplay]." DescriptionLocId="AI.LaunchCondition.No9X" IsPredefined="true" Builds="DefaultBuild"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiRegsComponent">
+    <ROW Registry="Manufacturer" Root="-1" Key="Software\[Manufacturer]" Name="\"/>
+    <ROW Registry="Path" Root="-1" Key="Software\[Manufacturer]\[ProductName]" Name="Path" Value="[APPDIR]" Component_="ProductInformation"/>
+    <ROW Registry="ProductName" Root="-1" Key="Software\[Manufacturer]\[ProductName]" Name="\"/>
+    <ROW Registry="Software" Root="-1" Key="Software" Name="\"/>
+    <ROW Registry="Version" Root="-1" Key="Software\[Manufacturer]\[ProductName]" Name="Version" Value="[ProductVersion]" Component_="ProductInformation"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiThemeComponent">
+    <ATTRIBUTE name="UsedTheme" value="classic"/>
+  </COMPONENT>
+  <COMPONENT cid="caphyon.advinst.msicomp.MsiUpgradeComponent">
+    <ROW UpgradeCode="[|UpgradeCode]" VersionMin="0.0.1" VersionMax="[|ProductVersion]" Attributes="257" ActionProperty="OLDPRODUCTS"/>
+    <ROW UpgradeCode="[|UpgradeCode]" VersionMin="[|ProductVersion]" Attributes="2" ActionProperty="AI_NEWERPRODUCTFOUND"/>
+  </COMPONENT>
+</DOCUMENT>

+ 0 - 27
Client/config/config.ini

@@ -1,27 +0,0 @@
-[app]
-# 连接成功后立即上报给 Server(System/SetLang);同时用于本地兜底文案
-# zh / en,文案见 common/i18n.h
-lang=zh
-# 当前客户端版本,与服务端 [update] version 比较
-version=1.0.0
-
-[server]
-# 接入地址。服务端在 WSL2 时不要用 127.0.0.1(localhost 转发会连上但不传数据),填 wsl hostname -I
-host=192.168.94.166
-port=6000
-
-[protocol]
-# 包头魔数,十六进制(ASCII "IM"),须与服务端一致
-magic=0x494D
-# 单包 body 最大字节(须与服务端 [protocol] max_packet 一致)
-max_packet=1048576
-
-[client]
-# 心跳间隔(秒),协议建议 30
-heartbeat_sec=30
-
-[log]
-# 1=输出日志。Debug 会弹出控制台;Release 双击不弹窗,从命令行启动则打到该终端
-enable=1
-# debug / info / warn / error(低于该等级的不输出)
-level=debug

+ 19 - 0
Client/res/app.manifest

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0"
+          xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
+  <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="NIM" type="win32"/>
+  <description>NIM Client</description>
+  <dependency>
+    <dependentAssembly>
+      <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls"
+                        version="6.0.0.0" processorArchitecture="*"
+                        publicKeyToken="6595b64144ccf1df" language="*"/>
+    </dependentAssembly>
+  </dependency>
+  <application xmlns="urn:schemas-microsoft-com:asm.v3">
+    <windowsSettings>
+      <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
+      <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
+    </windowsSettings>
+  </application>
+</assembly>

+ 1 - 0
Client/res/app.rc

@@ -1 +1,2 @@
 1 ICON "icons/app.ico"
+1 RT_MANIFEST "app.manifest"

+ 11 - 9
Client/res/qss/app.qss

@@ -10,11 +10,13 @@ QMainWindow, QDialog {
     background: #FFFFFF;
 }
 QToolTip {
-    background: #4C4C4C;
-    color: #FFFFFF;
-    border: 1px solid #4C4C4C;
-    padding: 4px 8px;
+    background-color: #FFFFFF;
+    color: #1A1A1A;
+    border: 1px solid #E0E0E0;
+    padding: 5px 8px;
     border-radius: 2px;
+    font-size: 12px;
+    opacity: 255;
 }
 QLineEdit {
     background: #FFFFFF;
@@ -47,14 +49,14 @@ QTextEdit {
 }
 QPushButton {
     border-radius: 2px;
-    font-size: 14px;
+    font-size: 12px;
 }
 QPushButton[role="primary"] {
     background: #2E2E2E;
     color: #FFFFFF;
     border: 1px solid #2E2E2E;
-    padding: 8px 16px;
-    font-size: 15px;
+    padding: 0 16px;
+    font-size: 13px;
 }
 QPushButton[role="primary"]:hover {
     background: #3E3E3E;
@@ -73,7 +75,7 @@ QPushButton[role="ghost"] {
     background: #FFFFFF;
     color: #1A1A1A;
     border: 1px solid #D9D9D9;
-    padding: 7px 14px;
+    padding: 0 14px;
 }
 QPushButton[role="ghost"]:hover {
     background: #F5F5F5;
@@ -96,7 +98,7 @@ QPushButton[role="danger"] {
     background: #FFFFFF;
     color: #FA5151;
     border: 1px solid #FA5151;
-    padding: 7px 14px;
+    padding: 0 14px;
 }
 QPushButton[role="danger"]:hover {
     background: #FFF1F1;

+ 29 - 4
Client/src/app/main.cpp

@@ -7,14 +7,33 @@
 #include "Updater.h"
 
 #include <QtWidgets/QApplication>
+#include <QColor>
 #include <QFont>
+#include <QPalette>
 
-int main(int argc, char *argv[])
+#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
+#include <QGuiApplication>
+#endif
+
+namespace
 {
+    void setupHighDpi()
+    {
 #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
-    QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
-    QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
+        QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
+        QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
+#endif
+#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
+        QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
+            Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
 #endif
+        qputenv("QT_AUTO_SCREEN_SCALE_FACTOR", "1");
+    }
+}
+
+int main(int argc, char *argv[])
+{
+    setupHighDpi();
 
     int applyRc = 0;
     if (Updater::runApplyUpdateIfRequested(argc, argv, &applyRc))
@@ -25,8 +44,14 @@ int main(int argc, char *argv[])
     AppConfig::instance().load();
     UserSettings::instance().load();
     AppTheme::loadFonts();
-    app.setFont(QFont(AppTheme::fontFamily(), 9));
+    app.setFont(AppTheme::appFont());
     app.setStyleSheet(AppTheme::globalQss());
+    {
+        QPalette pal = app.palette();
+        pal.setColor(QPalette::ToolTipBase, QColor(QStringLiteral("#FFFFFF")));
+        pal.setColor(QPalette::ToolTipText, QColor(QStringLiteral("#1A1A1A")));
+        app.setPalette(pal);
+    }
     app.setApplicationName(QStringLiteral("NIM"));
     app.setOrganizationName(QStringLiteral("NIM"));
     app.setWindowIcon(UiHelpers::appIcon());

+ 9 - 5
Client/src/auth/ChangePasswordDialog.cpp

@@ -37,12 +37,16 @@ ChangePasswordDialog::ChangePasswordDialog(QWidget *parent)
 
     const auto &cfg = AppConfig::instance();
     m_old = UiHelpers::passwordEdit(QStringLiteral("当前密码"), form);
-    m_old->setMaxLength(cfg.passwordMax());
-    m_password = UiHelpers::passwordEdit(
-        QStringLiteral("新密码(%1-%2 位)").arg(cfg.passwordMin()).arg(cfg.passwordMax()), form);
-    m_password->setMaxLength(cfg.passwordMax());
+    m_password = UiHelpers::passwordEdit(QStringLiteral("新密码"), form);
     m_confirm = UiHelpers::passwordEdit(QStringLiteral("确认新密码"), form);
-    m_confirm->setMaxLength(cfg.passwordMax());
+    if (cfg.passwordMax() > 0)
+    {
+        m_old->setMaxLength(cfg.passwordMax());
+        m_password->setPlaceholderText(
+            QStringLiteral("新密码(%1-%2 位)").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
+        m_password->setMaxLength(cfg.passwordMax());
+        m_confirm->setMaxLength(cfg.passwordMax());
+    }
     m_error = UiHelpers::errorLabel(form);
 
     m_submit = UiHelpers::primaryButton(QStringLiteral("确 定"), form);

+ 25 - 7
Client/src/auth/ForgotPasswordDialog.cpp

@@ -52,6 +52,8 @@ ForgotPasswordDialog::ForgotPasswordDialog(QWidget *parent)
 
     applyLimits();
 
+    m_smsWait = new UiHelpers::SmsCountdown(m_getCode, this);
+
     connect(m_getCode, &QPushButton::clicked, this, &ForgotPasswordDialog::onGetCode);
     connect(m_submit, &QPushButton::clicked, this, &ForgotPasswordDialog::onSubmit);
     connect(back, &QPushButton::clicked, this, &ForgotPasswordDialog::backRequested);
@@ -65,6 +67,8 @@ void ForgotPasswordDialog::resetForm()
     m_password->clear();
     m_confirm->clear();
     UiHelpers::setError(m_error, QString());
+    if (m_smsWait)
+        m_smsWait->stop();
     setBusy(false);
     m_uidEdit->setFocus();
 }
@@ -72,24 +76,36 @@ void ForgotPasswordDialog::resetForm()
 void ForgotPasswordDialog::applyLimits()
 {
     const auto &cfg = AppConfig::instance();
-    if (m_phone)
+    if (m_phone && cfg.phoneLen() > 0)
         m_phone->setMaxLength(cfg.phoneLen());
-    if (m_code)
+    if (m_code && cfg.codeLen() > 0)
         m_code->setMaxLength(cfg.codeLen());
-    if (m_password)
+    if (m_password && cfg.passwordMax() > 0)
     {
         m_password->setPlaceholderText(
             QStringLiteral("新密码(%1-%2 位)").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
         m_password->setMaxLength(cfg.passwordMax());
     }
-    if (m_confirm)
+    if (m_confirm && cfg.passwordMax() > 0)
         m_confirm->setMaxLength(cfg.passwordMax());
 }
 
 void ForgotPasswordDialog::setBusy(bool on)
 {
-    m_getCode->setEnabled(!on);
     m_submit->setEnabled(!on);
+    if (!m_smsWait || !m_smsWait->active())
+        m_getCode->setEnabled(!on);
+}
+
+void ForgotPasswordDialog::startSmsCountdown(const QJsonObject &reply)
+{
+    int sec = reply.value(QStringLiteral("interval")).toInt();
+    if (sec <= 0)
+        sec = AppConfig::instance().smsIntervalSec();
+    if (sec <= 0)
+        sec = 60;
+    if (m_smsWait)
+        m_smsWait->start(sec);
 }
 
 void ForgotPasswordDialog::onGetCode()
@@ -101,15 +117,16 @@ void ForgotPasswordDialog::onGetCode()
         return;
     }
     UiHelpers::setError(m_error, QString());
-    setBusy(true);
+    m_getCode->setEnabled(false);
     QPointer<ForgotPasswordDialog> self(this);
     ImClient::instance().sendCode(m_phone->text().trimmed(), QStringLiteral("reset"),
         [self](const QJsonObject &reply) {
             if (!self)
                 return;
-            self->setBusy(false);
             if (!ImClient::ok(reply))
             {
+                if (!self->m_smsWait || !self->m_smsWait->active())
+                    self->m_getCode->setEnabled(true);
                 UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
                 return;
             }
@@ -117,6 +134,7 @@ void ForgotPasswordDialog::onGetCode()
             if (!sms.isEmpty())
                 self->m_code->setText(sms);
             UiHelpers::setError(self->m_error, QString());
+            self->startSmsCountdown(reply);
             UiHelpers::showToast(self->window(), QStringLiteral("验证码已发送"));
         });
 }

+ 5 - 0
Client/src/auth/ForgotPasswordDialog.h

@@ -1,10 +1,13 @@
 #pragma once
 
+#include "UiHelpers.h"
+
 #include <QWidget>
 
 class QLineEdit;
 class QLabel;
 class QPushButton;
+class QJsonObject;
 
 class ForgotPasswordDialog : public QWidget
 {
@@ -24,7 +27,9 @@ private:
     void onGetCode();
     void onSubmit();
     void setBusy(bool on);
+    void startSmsCountdown(const QJsonObject &reply);
 
+    UiHelpers::SmsCountdown *m_smsWait = nullptr;
     QLineEdit *m_uidEdit = nullptr;
     QLineEdit *m_phone = nullptr;
     QLineEdit *m_code = nullptr;

+ 2 - 2
Client/src/auth/LoginWindow.cpp

@@ -238,9 +238,9 @@ void LoginWindow::applySavedLogin()
 void LoginWindow::applyServerLimits()
 {
     const auto &cfg = AppConfig::instance();
-    if (m_account)
+    if (m_account && cfg.accountMax() > 0)
         m_account->setMaxLength(cfg.accountMax());
-    if (m_password)
+    if (m_password && cfg.passwordMax() > 0)
         m_password->setMaxLength(cfg.passwordMax());
     if (m_registerPage)
         m_registerPage->applyLimits();

+ 27 - 9
Client/src/auth/RegisterDialog.cpp

@@ -55,6 +55,8 @@ RegisterDialog::RegisterDialog(QWidget *parent)
 
     applyLimits();
 
+    m_smsWait = new UiHelpers::SmsCountdown(m_getCode, this);
+
     connect(m_getCode, &QPushButton::clicked, this, &RegisterDialog::onGetCode);
     connect(m_submit, &QPushButton::clicked, this, &RegisterDialog::onSubmit);
     connect(back, &QPushButton::clicked, this, &RegisterDialog::backRequested);
@@ -73,25 +75,25 @@ QString RegisterDialog::nickname() const
 void RegisterDialog::applyLimits()
 {
     const auto &cfg = AppConfig::instance();
-    if (m_nickname)
+    if (m_nickname && cfg.nicknameMax() > 0)
         m_nickname->setMaxLength(cfg.nicknameMax());
-    if (m_account)
+    if (m_account && cfg.accountMax() > 0)
     {
         m_account->setPlaceholderText(
             QStringLiteral("账号(%1-%2 位字母或数字)").arg(cfg.accountMin()).arg(cfg.accountMax()));
         m_account->setMaxLength(cfg.accountMax());
     }
-    if (m_phone)
+    if (m_phone && cfg.phoneLen() > 0)
         m_phone->setMaxLength(cfg.phoneLen());
-    if (m_code)
+    if (m_code && cfg.codeLen() > 0)
         m_code->setMaxLength(cfg.codeLen());
-    if (m_password)
+    if (m_password && cfg.passwordMax() > 0)
     {
         m_password->setPlaceholderText(
             QStringLiteral("密码(%1-%2 位)").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
         m_password->setMaxLength(cfg.passwordMax());
     }
-    if (m_confirm)
+    if (m_confirm && cfg.passwordMax() > 0)
         m_confirm->setMaxLength(cfg.passwordMax());
 }
 
@@ -105,14 +107,28 @@ void RegisterDialog::resetForm()
     m_password->clear();
     m_confirm->clear();
     UiHelpers::setError(m_error, QString());
+    if (m_smsWait)
+        m_smsWait->stop();
     setBusy(false);
     m_nickname->setFocus();
 }
 
 void RegisterDialog::setBusy(bool on)
 {
-    m_getCode->setEnabled(!on);
     m_submit->setEnabled(!on);
+    if (!m_smsWait || !m_smsWait->active())
+        m_getCode->setEnabled(!on);
+}
+
+void RegisterDialog::startSmsCountdown(const QJsonObject &reply)
+{
+    int sec = reply.value(QStringLiteral("interval")).toInt();
+    if (sec <= 0)
+        sec = AppConfig::instance().smsIntervalSec();
+    if (sec <= 0)
+        sec = 60;
+    if (m_smsWait)
+        m_smsWait->start(sec);
 }
 
 void RegisterDialog::onGetCode()
@@ -125,14 +141,15 @@ void RegisterDialog::onGetCode()
         return;
     }
     UiHelpers::setError(m_error, QString());
-    setBusy(true);
+    m_getCode->setEnabled(false);
     QPointer<RegisterDialog> self(this);
     ImClient::instance().sendCode(phone, QStringLiteral("register"), [self](const QJsonObject &reply) {
         if (!self)
             return;
-        self->setBusy(false);
         if (!ImClient::ok(reply))
         {
+            if (!self->m_smsWait || !self->m_smsWait->active())
+                self->m_getCode->setEnabled(true);
             UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
             return;
         }
@@ -140,6 +157,7 @@ void RegisterDialog::onGetCode()
         if (!sms.isEmpty())
             self->m_code->setText(sms);
         UiHelpers::setError(self->m_error, QString());
+        self->startSmsCountdown(reply);
         UiHelpers::showToast(self->window(), QStringLiteral("验证码已发送"));
     });
 }

+ 5 - 0
Client/src/auth/RegisterDialog.h

@@ -1,10 +1,13 @@
 #pragma once
 
+#include "UiHelpers.h"
+
 #include <QWidget>
 
 class QLineEdit;
 class QLabel;
 class QPushButton;
+class QJsonObject;
 
 class RegisterDialog : public QWidget
 {
@@ -27,7 +30,9 @@ private:
     void onGetCode();
     void onSubmit();
     void setBusy(bool on);
+    void startSmsCountdown(const QJsonObject &reply);
 
+    UiHelpers::SmsCountdown *m_smsWait = nullptr;
     QLineEdit *m_nickname = nullptr;
     QLineEdit *m_account = nullptr;
     QLineEdit *m_phone = nullptr;

+ 44 - 16
Client/src/chat/StickerPanel.cpp

@@ -1,13 +1,13 @@
 #include "StickerPanel.h"
 #include "StickerCache.h"
-#include "AppTheme.h"
 
+#include <QColor>
 #include <QFrame>
+#include <QGraphicsDropShadowEffect>
 #include <QGridLayout>
 #include <QIcon>
 #include <QLabel>
 #include <QPushButton>
-#include <QPushButton>
 #include <QScrollArea>
 #include <QShowEvent>
 #include <QStackedWidget>
@@ -17,23 +17,41 @@
 StickerPanel::StickerPanel(QWidget *parent)
     : QWidget(parent)
 {
-    setFixedSize(360, 260);
-    setStyleSheet(QStringLiteral(
-        "StickerPanel { background:#FFFFFF; border:1px solid #E5E5E5; }"));
+    setAttribute(Qt::WA_TranslucentBackground, true);
+    setAutoFillBackground(false);
+    setFixedSize(376, 276);
+
+    auto *outer = new QVBoxLayout(this);
+    outer->setContentsMargins(8, 8, 8, 8);
+    outer->setSpacing(0);
+
+    auto *card = new QFrame(this);
+    card->setObjectName(QStringLiteral("stickerCard"));
+    card->setAttribute(Qt::WA_StyledBackground, true);
+    card->setStyleSheet(QStringLiteral(
+        "#stickerCard { background:#FFFFFF; border:1px solid #E8E8E8; border-radius:4px; }"));
+    auto *shadow = new QGraphicsDropShadowEffect(card);
+    shadow->setBlurRadius(22);
+    shadow->setOffset(0, 3);
+    shadow->setColor(QColor(0, 0, 0, 50));
+    card->setGraphicsEffect(shadow);
 
-    auto *lay = new QVBoxLayout(this);
+    auto *lay = new QVBoxLayout(card);
     lay->setContentsMargins(0, 0, 0, 0);
     lay->setSpacing(0);
 
-    m_tabs = new QTabBar(this);
+    m_tabs = new QTabBar(card);
     m_tabs->setDrawBase(false);
     m_tabs->setExpanding(false);
     m_tabs->setStyleSheet(QStringLiteral(
-        "QTabBar::tab { height:32px; padding:0 12px; color:#666; background:#FAFAFA; border:none; }"
+        "QTabBar { background:#FFFFFF; }"
+        "QTabBar::tab { height:32px; padding:0 12px; color:#666; background:#FFFFFF; border:none; }"
         "QTabBar::tab:selected { color:#07C160; background:#FFFFFF; }"));
-    m_pages = new QStackedWidget(this);
+    m_pages = new QStackedWidget(card);
+    m_pages->setStyleSheet(QStringLiteral("QStackedWidget { background:#FFFFFF; border:none; }"));
     lay->addWidget(m_tabs);
     lay->addWidget(m_pages, 1);
+    outer->addWidget(card);
 
     connect(m_tabs, &QTabBar::currentChanged, m_pages, &QStackedWidget::setCurrentIndex);
     connect(&StickerCache::instance(), &StickerCache::ready, this, &StickerPanel::rebuild);
@@ -77,7 +95,7 @@ void StickerPanel::rebuild()
     {
         auto *empty = new QLabel(QStringLiteral("表情包加载中…"), m_pages);
         empty->setAlignment(Qt::AlignCenter);
-        empty->setStyleSheet(QStringLiteral("color:#B0B0B0;"));
+        empty->setStyleSheet(QStringLiteral("color:#B0B0B0; background:#FFFFFF;"));
         m_pages->addWidget(empty);
     }
     if (keep >= 0 && keep < m_tabs->count())
@@ -88,15 +106,23 @@ QWidget *StickerPanel::buildPackPage(int packIndex)
 {
     const auto packs = StickerCache::instance().packs();
     auto *page = new QWidget(m_pages);
+    page->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
     auto *outer = new QVBoxLayout(page);
     outer->setContentsMargins(0, 0, 0, 0);
     auto *scroll = new QScrollArea(page);
     scroll->setWidgetResizable(true);
     scroll->setFrameShape(QFrame::NoFrame);
+    scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    scroll->setStyleSheet(QStringLiteral(
+        "QScrollArea { background:#FFFFFF; border:none; }"
+        "QScrollBar:vertical { background:#FFFFFF; width:8px; }"
+        "QScrollBar::handle:vertical { background:#D0D0D0; border-radius:4px; }"
+        "QScrollBar:horizontal { height:0px; }"));
     auto *host = new QWidget(scroll);
+    host->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
     auto *grid = new QGridLayout(host);
-    grid->setContentsMargins(10, 8, 10, 8);
-    grid->setSpacing(6);
+    grid->setContentsMargins(8, 8, 8, 8);
+    grid->setSpacing(4);
     if (packIndex >= 0 && packIndex < packs.size())
     {
         const auto &pack = packs[packIndex];
@@ -107,12 +133,14 @@ QWidget *StickerPanel::buildPackPage(int packIndex)
             auto *btn = new QPushButton(host);
             btn->setFlat(true);
             btn->setCursor(Qt::PointingHandCursor);
-            btn->setFixedSize(56, 56);
-            btn->setToolTip(s.name);
-            const QPixmap pm = StickerCache::instance().pixmap(pack.id, s.code, 48);
+            btn->setFixedSize(50, 50);
+            btn->setStyleSheet(QStringLiteral(
+                "QPushButton { background:#FFFFFF; border:none; border-radius:4px; }"
+                "QPushButton:hover { background:#F5F5F5; }"));
+            const QPixmap pm = StickerCache::instance().pixmap(pack.id, s.code, 44);
             if (!pm.isNull())
                 btn->setIcon(QIcon(pm));
-            btn->setIconSize(QSize(48, 48));
+            btn->setIconSize(QSize(44, 44));
             const qint64 packId = pack.id;
             const QString code = s.code;
             connect(btn, &QPushButton::clicked, this, [this, packId, code]() {

+ 52 - 147
Client/src/common/AppConfig.cpp

@@ -1,142 +1,36 @@
 #include "AppConfig.h"
 #include "Logger.h"
 
-#include <QCoreApplication>
 #include <QDir>
-#include <QFileInfo>
 #include <QJsonValue>
 #include <QList>
-#include <QSettings>
+#include <QStandardPaths>
 #include <QtGlobal>
 
-namespace
-{
-    quint16 parseMagic(const QString &raw, quint16 fallback)
-    {
-        QString s = raw.trimmed();
-        if (s.startsWith(QLatin1String("0x"), Qt::CaseInsensitive))
-            s = s.mid(2);
-        bool ok = false;
-        const uint v = s.toUInt(&ok, 16);
-        if (!ok || v > 0xFFFFu)
-            return fallback;
-        return static_cast<quint16>(v);
-    }
-
-    int parseInt(const QVariant &v, int fallback)
-    {
-        bool ok = false;
-        const int n = v.toString().trimmed().toInt(&ok);
-        return ok ? n : fallback;
-    }
-
-    quint32 parseU32(const QVariant &v, quint32 fallback)
-    {
-        bool ok = false;
-        const uint n = v.toString().trimmed().toUInt(&ok);
-        return ok ? static_cast<quint32>(n) : fallback;
-    }
-
-    bool parseBool(const QVariant &v, bool fallback)
-    {
-        const QString s = v.toString().trimmed().toLower();
-        if (s.isEmpty())
-            return fallback;
-        if (s == QLatin1String("1") || s == QLatin1String("true")
-            || s == QLatin1String("yes") || s == QLatin1String("on"))
-            return true;
-        if (s == QLatin1String("0") || s == QLatin1String("false")
-            || s == QLatin1String("no") || s == QLatin1String("off"))
-            return false;
-        return fallback;
-    }
-
-    void applyLogger(bool enabled, int level)
-    {
-        Logger::setup(enabled, static_cast<Logger::Level>(level));
-    }
-}
-
 AppConfig &AppConfig::instance()
 {
     static AppConfig cfg;
     return cfg;
 }
 
-bool AppConfig::load(const QString &explicitPath)
+QString AppConfig::dataDir()
 {
-    QStringList candidates;
-    if (!explicitPath.isEmpty())
-        candidates << explicitPath;
-
-    const QStringList args = QCoreApplication::arguments();
-    if (args.size() >= 2 && !args.at(1).isEmpty() && !args.at(1).startsWith(QLatin1Char('-')))
-        candidates << args.at(1);
-
-    const QString appDir = QCoreApplication::applicationDirPath();
-    candidates << appDir + QStringLiteral("/config/config.ini")
-               << appDir + QStringLiteral("/config.ini")
-               << QDir::cleanPath(appDir + QStringLiteral("/../config/config.ini"))
-               << QDir::cleanPath(appDir + QStringLiteral("/../../config/config.ini"))
-               << QDir::cleanPath(appDir + QStringLiteral("/../../../config/config.ini"));
-
-    QString found;
-    for (const QString &p : candidates)
-    {
-        if (p.isEmpty())
-            continue;
-        if (QFileInfo::exists(p))
-        {
-            found = QFileInfo(p).absoluteFilePath();
-            break;
-        }
-    }
-
-    if (found.isEmpty())
-    {
-        applyLogger(m_logEnabled, m_logLevel);
-        LOG_WARN(QStringLiteral("config.ini not found, using defaults"));
-        m_loaded = false;
-        m_path.clear();
-        return false;
-    }
-
-    QSettings ini(found, QSettings::IniFormat);
-#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
-    ini.setIniCodec("UTF-8");
-#endif
-
-    m_host = ini.value(QStringLiteral("server/host"), m_host).toString().trimmed();
-    if (m_host.isEmpty())
-        m_host = QStringLiteral("127.0.0.1");
-    m_port = static_cast<quint16>(qBound(1, parseInt(ini.value(QStringLiteral("server/port")), m_port), 65535));
-    m_magic = parseMagic(ini.value(QStringLiteral("protocol/magic"), QStringLiteral("0x494D")).toString(), m_magic);
-    m_maxPacket = parseU32(ini.value(QStringLiteral("protocol/max_packet")), m_maxPacket);
-    if (m_maxPacket < 1024)
-        m_maxPacket = 1024;
-    m_heartbeatSec = qMax(5, parseInt(ini.value(QStringLiteral("client/heartbeat_sec")), m_heartbeatSec));
-    {
-        const QByteArray langBytes = ini.value(QStringLiteral("app/lang"), QStringLiteral("zh"))
-                                         .toString().trimmed().toUtf8();
-        m_lang = im::i18n::parse_lang(langBytes.constData());
-        im::i18n::set_lang(m_lang);
-    }
-    m_appVersion = ini.value(QStringLiteral("app/version"), m_appVersion).toString().trimmed();
-    if (m_appVersion.isEmpty())
-        m_appVersion = QStringLiteral("1.0.0");
-    m_logEnabled = parseBool(ini.value(QStringLiteral("log/enable")), m_logEnabled);
-    m_logLevel = static_cast<int>(Logger::parseLevel(
-        ini.value(QStringLiteral("log/level"), QStringLiteral("debug")).toString()));
-    applyLogger(m_logEnabled, m_logLevel);
+    const QString home = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
+    const QString dir = QDir(home).filePath(QStringLiteral("NIM"));
+    QDir().mkpath(dir);
+    return dir;
+}
 
-    m_path = found;
-    m_loaded = true;
-    LOG_INFO(QStringLiteral("loaded %1 (%2:%3 magic=0x%4 log=%5/%6)")
-                 .arg(found, m_host)
-                 .arg(static_cast<int>(m_port))
-                 .arg(static_cast<int>(m_magic), 4, 16, QLatin1Char('0'))
-                 .arg(m_logEnabled ? QStringLiteral("on") : QStringLiteral("off"))
-                 .arg(QLatin1String(Logger::levelName(static_cast<Logger::Level>(m_logLevel)))));
+bool AppConfig::load()
+{
+    im::i18n::set_lang(ClientConfig::kLang);
+    Logger::setup(ClientConfig::kLogEnabled, static_cast<Logger::Level>(ClientConfig::kLogLevel));
+    LOG_INFO(QStringLiteral("client %1 %2:%3 magic=0x%4 log=%5/%6")
+                 .arg(appVersion(), host())
+                 .arg(static_cast<int>(port()))
+                 .arg(static_cast<int>(magic()), 4, 16, QLatin1Char('0'))
+                 .arg(logEnabled() ? QStringLiteral("on") : QStringLiteral("off"))
+                 .arg(QLatin1String(Logger::levelName(static_cast<Logger::Level>(logLevel())))));
     return true;
 }
 
@@ -145,37 +39,38 @@ void AppConfig::applyServerLimits(const QJsonObject &limits)
     if (limits.isEmpty())
         return;
 
-    auto take = [](const QJsonObject &o, const QString &key, int fallback) -> int {
+    auto take = [](const QJsonObject &o, const QString &key) -> int {
         const QJsonValue v = o.value(key);
         if (v.isDouble())
             return static_cast<int>(v.toDouble());
         if (v.isString())
-        {
-            bool ok = false;
-            const int n = v.toString().toInt(&ok);
-            return ok ? n : fallback;
-        }
-        return fallback;
+            return v.toString().toInt();
+        return 0;
     };
 
-    m_accountMin = qMax(1, take(limits, QStringLiteral("account_min"), m_accountMin));
-    m_accountMax = qMax(m_accountMin, take(limits, QStringLiteral("account_max"), m_accountMax));
-    m_passwordMin = qMax(1, take(limits, QStringLiteral("password_min"), m_passwordMin));
-    m_passwordMax = qMax(m_passwordMin, take(limits, QStringLiteral("password_max"), m_passwordMax));
-    m_phoneLen = qMax(1, take(limits, QStringLiteral("phone_len"), m_phoneLen));
-    m_nicknameMax = qMax(1, take(limits, QStringLiteral("nickname_max"), m_nicknameMax));
-    m_signatureMax = qMax(1, take(limits, QStringLiteral("signature_max"), m_signatureMax));
-    m_contentMax = qMax(1, take(limits, QStringLiteral("content_max"), m_contentMax));
-    m_previewMax = qMax(1, take(limits, QStringLiteral("preview_max"), m_previewMax));
-    m_historyDefault = qMax(1, take(limits, QStringLiteral("history_default"), m_historyDefault));
-    m_historyMax = qMax(m_historyDefault, take(limits, QStringLiteral("history_max"), m_historyMax));
-    m_codeLen = qMax(1, take(limits, QStringLiteral("code_len"), m_codeLen));
-    m_avatarMax = qMax(1024, take(limits, QStringLiteral("avatar_max"), m_avatarMax));
-    m_fileMax = qMax(1024, take(limits, QStringLiteral("file_max"), m_fileMax));
-    LOG_INFO(QStringLiteral("server limits account=%1-%2 password=%3-%4 file=%5 avatar=%6")
+    m_accountMin = take(limits, QStringLiteral("account_min"));
+    m_accountMax = take(limits, QStringLiteral("account_max"));
+    m_passwordMin = take(limits, QStringLiteral("password_min"));
+    m_passwordMax = take(limits, QStringLiteral("password_max"));
+    m_phoneLen = take(limits, QStringLiteral("phone_len"));
+    m_nicknameMax = take(limits, QStringLiteral("nickname_max"));
+    m_signatureMax = take(limits, QStringLiteral("signature_max"));
+    m_contentMax = take(limits, QStringLiteral("content_max"));
+    m_previewMax = take(limits, QStringLiteral("preview_max"));
+    m_historyDefault = take(limits, QStringLiteral("history_default"));
+    m_historyMax = take(limits, QStringLiteral("history_max"));
+    m_codeLen = take(limits, QStringLiteral("code_len"));
+    m_avatarMax = take(limits, QStringLiteral("avatar_max"));
+    m_fileMax = take(limits, QStringLiteral("file_max"));
+    m_maxPacket = static_cast<quint32>(qMax(0, take(limits, QStringLiteral("max_packet"))));
+    m_heartbeatSec = take(limits, QStringLiteral("heartbeat_sec"));
+    m_smsIntervalSec = take(limits, QStringLiteral("interval_sec"));
+    LOG_INFO(QStringLiteral("server limits account=%1-%2 password=%3-%4 file=%5 avatar=%6 "
+                            "max_packet=%7 heartbeat=%8s sms_interval=%9s")
                  .arg(m_accountMin).arg(m_accountMax)
                  .arg(m_passwordMin).arg(m_passwordMax)
-                 .arg(m_fileMax).arg(m_avatarMax));
+                 .arg(m_fileMax).arg(m_avatarMax)
+                 .arg(m_maxPacket).arg(m_heartbeatSec).arg(m_smsIntervalSec));
 }
 
 void AppConfig::applyServerCdn(const QJsonObject &cdn)
@@ -212,7 +107,7 @@ bool AppConfig::hasNewerUpdate() const
         return out;
     };
     const QList<int> remote = parts(m_updateVersion);
-    const QList<int> local = parts(m_appVersion);
+    const QList<int> local = parts(appVersion());
     for (int i = 0; i < 3; ++i)
     {
         if (remote.at(i) > local.at(i))
@@ -241,6 +136,8 @@ QString AppConfig::resourceUrl(const QString &path) const
 
 bool AppConfig::validAccount(const QString &account) const
 {
+    if (m_accountMin <= 0 || m_accountMax <= 0)
+        return false;
     if (account.size() < m_accountMin || account.size() > m_accountMax)
         return false;
     for (const QChar c : account)
@@ -257,11 +154,15 @@ bool AppConfig::validAccount(const QString &account) const
 
 bool AppConfig::validPassword(const QString &password) const
 {
+    if (m_passwordMin <= 0 || m_passwordMax <= 0)
+        return false;
     return password.size() >= m_passwordMin && password.size() <= m_passwordMax;
 }
 
 bool AppConfig::validPhone(const QString &phone) const
 {
+    if (m_phoneLen <= 0)
+        return false;
     if (phone.size() != m_phoneLen || phone.isEmpty() || phone.at(0) != QLatin1Char('1'))
         return false;
     for (const QChar c : phone)
@@ -274,11 +175,15 @@ bool AppConfig::validPhone(const QString &phone) const
 
 bool AppConfig::validNickname(const QString &nickname) const
 {
+    if (m_nicknameMax <= 0)
+        return false;
     const QString t = nickname.trimmed();
     return !t.isEmpty() && t.size() <= m_nicknameMax;
 }
 
 bool AppConfig::validSignature(const QString &signature) const
 {
+    if (m_signatureMax < 0)
+        return false;
     return signature.size() <= m_signatureMax;
 }

+ 31 - 39
Client/src/common/AppConfig.h

@@ -2,27 +2,31 @@
 
 #include <QJsonObject>
 #include <QString>
+#include <QtGlobal>
 
-#include "common/i18n.h"
+#include "ClientConfig.h"
 
 class AppConfig
 {
 public:
     static AppConfig &instance();
 
-    bool load(const QString &explicitPath = QString());
-    QString path() const { return m_path; }
-    bool loaded() const { return m_loaded; }
+    static QString dataDir();
+
+    bool load();
+
+    QString host() const { return QString::fromLatin1(ClientConfig::kHost); }
+    quint16 port() const { return ClientConfig::kPort; }
+    quint16 magic() const { return ClientConfig::kMagic; }
+    bool logEnabled() const { return ClientConfig::kLogEnabled; }
+    int logLevel() const { return ClientConfig::kLogLevel; }
+    im::i18n::Lang lang() const { return ClientConfig::kLang; }
+    QString appVersion() const { return QString::fromLatin1(ClientConfig::kVersion); }
 
-    QString host() const { return m_host; }
-    quint16 port() const { return m_port; }
-    quint16 magic() const { return m_magic; }
     quint32 maxPacket() const { return m_maxPacket; }
     int heartbeatSec() const { return m_heartbeatSec; }
-    bool logEnabled() const { return m_logEnabled; }
-    int logLevel() const { return m_logLevel; }
+    int smsIntervalSec() const { return m_smsIntervalSec; }
 
-    // 业务限制以服务端 SetLang 下发为准;下列为连上之前的本地兜底。
     int accountMin() const { return m_accountMin; }
     int accountMax() const { return m_accountMax; }
     int passwordMin() const { return m_passwordMin; }
@@ -38,8 +42,6 @@ public:
     int avatarMax() const { return m_avatarMax; }
     int fileMax() const { return m_fileMax; }
     QString cdnDomain() const { return m_cdnDomain; }
-    im::i18n::Lang lang() const { return m_lang; }
-    QString appVersion() const { return m_appVersion; }
     QString updateVersion() const { return m_updateVersion; }
     QString updateUrl() const { return m_updateUrl; }
     QString updateNotes() const { return m_updateNotes; }
@@ -59,34 +61,24 @@ public:
 private:
     AppConfig() = default;
 
-    QString m_path;
-    bool m_loaded = false;
-
-    QString m_host = QStringLiteral("127.0.0.1");
-    quint16 m_port = 6000;
-    quint16 m_magic = 0x494D;
-    quint32 m_maxPacket = 1048576;
-    int m_heartbeatSec = 30;
-    bool m_logEnabled = true;
-    int m_logLevel = 0;
-
-    int m_accountMin = 4;
-    int m_accountMax = 16;
-    int m_passwordMin = 6;
-    int m_passwordMax = 20;
-    int m_phoneLen = 11;
-    int m_nicknameMax = 32;
-    int m_signatureMax = 64;
-    int m_contentMax = 4096;
-    int m_previewMax = 120;
-    int m_historyDefault = 50;
-    int m_historyMax = 100;
-    int m_codeLen = 6;
-    int m_avatarMax = 81920;
-    int m_fileMax = 524288;
+    quint32 m_maxPacket = 0;
+    int m_heartbeatSec = 0;
+    int m_smsIntervalSec = 0;
+    int m_accountMin = 0;
+    int m_accountMax = 0;
+    int m_passwordMin = 0;
+    int m_passwordMax = 0;
+    int m_phoneLen = 0;
+    int m_nicknameMax = 0;
+    int m_signatureMax = 0;
+    int m_contentMax = 0;
+    int m_previewMax = 0;
+    int m_historyDefault = 0;
+    int m_historyMax = 0;
+    int m_codeLen = 0;
+    int m_avatarMax = 0;
+    int m_fileMax = 0;
     QString m_cdnDomain;
-    im::i18n::Lang m_lang = im::i18n::Lang::Zh;
-    QString m_appVersion = QStringLiteral("1.0.0");
     QString m_updateVersion;
     QString m_updateUrl;
     QString m_updateNotes;

+ 23 - 11
Client/src/common/AppTheme.cpp

@@ -7,6 +7,8 @@
 #include <QFile>
 #include <QFont>
 #include <QFontDatabase>
+#include <QPalette>
+#include <QColor>
 
 namespace
 {
@@ -86,12 +88,20 @@ namespace
     {
         if (!qApp)
             return;
-        QFont f(g_fontFamily, 9);
-        qApp->setFont(f);
+        qApp->setFont(AppTheme::appFont());
         qApp->setStyleSheet(AppTheme::globalQss());
+        QPalette pal = qApp->palette();
+        pal.setColor(QPalette::ToolTipBase, QColor(QStringLiteral("#FFFFFF")));
+        pal.setColor(QPalette::ToolTipText, QColor(QStringLiteral("#1A1A1A")));
+        qApp->setPalette(pal);
     }
 }
 
+QFont AppTheme::appFont()
+{
+    return QFont(g_fontFamily.isEmpty() ? g_systemFamily : g_fontFamily, 9);
+}
+
 void AppTheme::loadFonts()
 {
     g_systemFamily = QFontDatabase::systemFont(QFontDatabase::GeneralFont).family();
@@ -122,8 +132,10 @@ void AppTheme::loadFonts()
         g_preset = FontYaHei;
     else if (saved == QLatin1String("alimama") && !g_alimamaFamily.isEmpty())
         g_preset = FontAlimama;
-    else
+    else if (saved == QLatin1String("system"))
         g_preset = FontSystem;
+    else
+        g_preset = !g_yaheiFamily.isEmpty() ? FontYaHei : FontSystem;
 
     g_fontFamily = familyOf(g_preset);
 }
@@ -141,13 +153,6 @@ AppTheme::FontPreset AppTheme::currentFontPreset()
 QVector<AppTheme::FontOption> AppTheme::availableFonts()
 {
     QVector<FontOption> opts;
-    FontOption sys;
-    sys.preset = FontSystem;
-    sys.key = QStringLiteral("system");
-    sys.label = QStringLiteral("系统默认");
-    sys.family = g_systemFamily;
-    opts.push_back(sys);
-
     if (!g_yaheiFamily.isEmpty())
     {
         FontOption yahei;
@@ -158,6 +163,13 @@ QVector<AppTheme::FontOption> AppTheme::availableFonts()
         opts.push_back(yahei);
     }
 
+    FontOption sys;
+    sys.preset = FontSystem;
+    sys.key = QStringLiteral("system");
+    sys.label = QStringLiteral("系统默认");
+    sys.family = g_systemFamily;
+    opts.push_back(sys);
+
     if (!g_alimamaFamily.isEmpty())
     {
         FontOption ali;
@@ -183,7 +195,7 @@ void AppTheme::setFontPreset(FontPreset preset)
         }
     }
     if (!ok)
-        preset = FontSystem;
+        preset = !g_yaheiFamily.isEmpty() ? FontYaHei : FontSystem;
 
     g_preset = preset;
     g_fontFamily = familyOf(preset);

+ 2 - 1
Client/src/common/AppTheme.h

@@ -2,7 +2,7 @@
 
 #include <QString>
 #include <QVector>
-
+#include <QFont>
 namespace AppTheme
 {
     static const char Primary[]        = "#07C160";
@@ -38,6 +38,7 @@ namespace AppTheme
     };
 
     void loadFonts();
+    QFont appFont();
     QString fontFamily();
     FontPreset currentFontPreset();
     QVector<FontOption> availableFonts();

+ 16 - 0
Client/src/common/ClientConfig.h

@@ -0,0 +1,16 @@
+#pragma once
+
+#include <cstdint>
+
+#include "common/i18n.h"
+
+namespace ClientConfig
+{
+constexpr const char *kHost = "49.232.135.144";
+constexpr uint16_t kPort = 6000;
+constexpr uint16_t kMagic = 0x494D;
+constexpr bool kLogEnabled = true;
+constexpr int kLogLevel = 0;
+constexpr const char *kVersion = "1.0.0";
+constexpr im::i18n::Lang kLang = im::i18n::Lang::Zh;
+}

+ 2 - 3
Client/src/common/LocalStore.cpp

@@ -1,4 +1,5 @@
 #include "LocalStore.h"
+#include "AppConfig.h"
 #include "Logger.h"
 
 #include <algorithm>
@@ -8,7 +9,6 @@
 #include <QDir>
 #include <QSqlError>
 #include <QSqlQuery>
-#include <QStandardPaths>
 #include <QVariant>
 
 namespace
@@ -30,8 +30,7 @@ LocalStore &LocalStore::instance()
 
 QString LocalStore::userDir(qint64 uid)
 {
-    const QString home = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
-    return QDir(home).filePath(QStringLiteral("NIM/%1").arg(uid));
+    return QDir(AppConfig::dataDir()).filePath(QString::number(uid));
 }
 
 QString LocalStore::userSubdir(qint64 uid, const QString &name)

+ 53 - 8
Client/src/common/UiHelpers.cpp

@@ -77,6 +77,52 @@ namespace
 
 namespace UiHelpers
 {
+    SmsCountdown::SmsCountdown(QPushButton *btn, QObject *parent)
+        : QObject(parent)
+        , m_btn(btn)
+    {
+        if (m_btn)
+            m_idleText = m_btn->text();
+        m_timer = new QTimer(this);
+        m_timer->setInterval(1000);
+        QObject::connect(m_timer, &QTimer::timeout, this, [this]() { tick(); });
+    }
+
+    void SmsCountdown::start(int seconds)
+    {
+        if (!m_btn || seconds <= 0)
+            return;
+        if (m_idleText.isEmpty())
+            m_idleText = m_btn->text();
+        m_left = seconds;
+        m_btn->setEnabled(false);
+        m_btn->setText(QStringLiteral("%1秒").arg(m_left));
+        m_timer->start();
+    }
+
+    void SmsCountdown::stop()
+    {
+        m_left = 0;
+        if (m_timer)
+            m_timer->stop();
+        if (!m_btn)
+            return;
+        m_btn->setText(m_idleText);
+        m_btn->setEnabled(true);
+    }
+
+    void SmsCountdown::tick()
+    {
+        --m_left;
+        if (m_left <= 0)
+        {
+            stop();
+            return;
+        }
+        if (m_btn)
+            m_btn->setText(QStringLiteral("%1秒").arg(m_left));
+    }
+
     void polish(QWidget *w)
     {
         if (!w)
@@ -94,7 +140,7 @@ namespace UiHelpers
         btn->setMinimumWidth(120);
         btn->setStyleSheet(QStringLiteral(
             "QPushButton { background-color:#2E2E2E; color:#FFFFFF; border:1px solid #2E2E2E;"
-            " font-size:15px; border-radius:2px; padding:8px 16px; }"
+            " font-size:13px; border-radius:2px; padding:0 16px; }"
             "QPushButton:hover { background-color:#3E3E3E; border-color:#3E3E3E; color:#FFFFFF; }"
             "QPushButton:pressed { background-color:#1A1A1A; border-color:#1A1A1A; color:#FFFFFF; }"
             "QPushButton:disabled { background-color:#C8C8C8; border-color:#C8C8C8; color:#FFFFFF; }"));
@@ -108,9 +154,10 @@ namespace UiHelpers
         btn->setFixedHeight(32);
         btn->setStyleSheet(QStringLiteral(
             "QPushButton { background-color:#FFFFFF; color:#1A1A1A; border:1px solid #D9D9D9;"
-            " padding:7px 14px; border-radius:2px; }"
+            " padding:0 14px; border-radius:2px; }"
             "QPushButton:hover { background-color:#F5F5F5; }"
-            "QPushButton:pressed { background-color:#EBEBEB; }"));
+            "QPushButton:pressed { background-color:#EBEBEB; }"
+            "QPushButton:disabled { color:#9A9A9A; border-color:#E5E5E5; background:#F7F7F7; }"));
         return btn;
     }
 
@@ -133,7 +180,7 @@ namespace UiHelpers
         btn->setFixedHeight(32);
         btn->setStyleSheet(QStringLiteral(
             "QPushButton { background-color:#FFFFFF; color:#FA5151; border:1px solid #FA5151;"
-            " padding:7px 14px; border-radius:2px; }"
+            " padding:0 14px; border-radius:2px; }"
             "QPushButton:hover { background-color:#FFF1F1; }"));
         return btn;
     }
@@ -148,7 +195,7 @@ namespace UiHelpers
         {
             btn->setStyleSheet(QStringLiteral(
                 "QPushButton { background-color:#2E2E2E; color:#FFFFFF; border:1px solid #2E2E2E;"
-                " font-size:12px; border-radius:2px; padding:2px 14px; }"
+                " font-size:12px; border-radius:2px; padding:0 14px; }"
                 "QPushButton:hover { background-color:#3E3E3E; }"
                 "QPushButton:disabled { background-color:#C8C8C8; border-color:#C8C8C8; color:#FFFFFF; }"));
         }
@@ -156,7 +203,7 @@ namespace UiHelpers
         {
             btn->setStyleSheet(QStringLiteral(
                 "QPushButton { background-color:#FFFFFF; color:#1A1A1A; border:1px solid #D9D9D9;"
-                " font-size:12px; border-radius:2px; padding:2px 14px; }"
+                " font-size:12px; border-radius:2px; padding:0 14px; }"
                 "QPushButton:hover { background-color:#F5F5F5; }"));
         }
         return btn;
@@ -179,11 +226,9 @@ namespace UiHelpers
         edit->setEchoMode(QLineEdit::Password);
         auto *toggle = edit->addAction(QIcon(eyeIcon(false)), QLineEdit::TrailingPosition);
         toggle->setCheckable(true);
-        toggle->setToolTip(QStringLiteral("显示密码"));
         QObject::connect(toggle, &QAction::toggled, edit, [edit, toggle](bool on) {
             edit->setEchoMode(on ? QLineEdit::Normal : QLineEdit::Password);
             toggle->setIcon(QIcon(eyeIcon(on)));
-            toggle->setToolTip(on ? QStringLiteral("隐藏密码") : QStringLiteral("显示密码"));
         });
         return edit;
     }

+ 75 - 1
Client/src/common/UiHelpers.h

@@ -2,17 +2,91 @@
 
 #include <QColor>
 #include <QIcon>
+#include <QLabel>
+#include <QObject>
 #include <QPixmap>
+#include <QResizeEvent>
+#include <QShowEvent>
+#include <QSizePolicy>
 #include <QString>
 #include <QWidget>
 
-class QLabel;
 class QLineEdit;
 class QPushButton;
 class QCheckBox;
+class QTimer;
 
 namespace UiHelpers
 {
+    class SmsCountdown : public QObject
+    {
+    public:
+        explicit SmsCountdown(QPushButton *btn, QObject *parent = nullptr);
+        void start(int seconds);
+        void stop();
+        bool active() const { return m_left > 0; }
+
+    private:
+        void tick();
+
+        QPushButton *m_btn = nullptr;
+        QTimer *m_timer = nullptr;
+        int m_left = 0;
+        QString m_idleText;
+    };
+    class ElideLabel : public QLabel
+    {
+    public:
+        explicit ElideLabel(const QString &text, QWidget *parent = nullptr)
+            : QLabel(parent)
+            , m_full(text)
+        {
+            setMinimumWidth(0);
+            setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred);
+            QLabel::setText(text);
+        }
+
+        void setFullText(const QString &text)
+        {
+            m_full = text;
+            applyElide();
+        }
+
+        QString fullText() const { return m_full; }
+
+        QSize sizeHint() const override
+        {
+            return QSize(24, QLabel::sizeHint().height());
+        }
+
+        QSize minimumSizeHint() const override
+        {
+            return QSize(0, QLabel::minimumSizeHint().height());
+        }
+
+    protected:
+        void resizeEvent(QResizeEvent *event) override
+        {
+            QLabel::resizeEvent(event);
+            applyElide();
+        }
+
+        void showEvent(QShowEvent *event) override
+        {
+            QLabel::showEvent(event);
+            applyElide();
+        }
+
+    private:
+        void applyElide()
+        {
+            const int w = qMax(1, contentsRect().width());
+            QLabel::setText(fontMetrics().elidedText(m_full, Qt::ElideRight, w));
+        }
+
+        QString m_full;
+    };
+
     void polish(QWidget *w);
 
     QPushButton *primaryButton(const QString &text, QWidget *parent = nullptr);

+ 1 - 1
Client/src/common/Updater.cpp

@@ -476,7 +476,7 @@ bool Updater::runApplyUpdateIfRequested(int argc, char **argv, int *exitCode)
     QApplication updaterApp(argc, argv);
     UserSettings::instance().load();
     AppTheme::loadFonts();
-    updaterApp.setFont(QFont(AppTheme::fontFamily(), 9));
+    updaterApp.setFont(AppTheme::appFont());
     updaterApp.setStyleSheet(AppTheme::globalQss());
     updaterApp.setApplicationName(QStringLiteral("NIM"));
     updaterApp.setOrganizationName(QStringLiteral("NIM"));

+ 2 - 24
Client/src/common/UserSettings.cpp

@@ -1,9 +1,7 @@
 #include "UserSettings.h"
 #include "AppConfig.h"
 
-#include <QCoreApplication>
 #include <QDir>
-#include <QFileInfo>
 #include <QRect>
 #include <QSettings>
 #include <QtGlobal>
@@ -52,14 +50,7 @@ UserSettings &UserSettings::instance()
 
 bool UserSettings::load()
 {
-    QString dir;
-    if (AppConfig::instance().loaded())
-        dir = QFileInfo(AppConfig::instance().path()).absolutePath();
-    else
-        dir = QDir::cleanPath(QCoreApplication::applicationDirPath() + QStringLiteral("/config"));
-
-    QDir().mkpath(dir);
-    m_path = dir + QStringLiteral("/user.ini");
+    m_path = QDir(AppConfig::dataDir()).filePath(QStringLiteral("user.ini"));
 
     QSettings ini(m_path, QSettings::IniFormat);
     applyIniCodec(ini);
@@ -76,19 +67,6 @@ bool UserSettings::load()
         m_autoLogin = false;
 
     m_fontPreset = ini.value(QStringLiteral("ui/font_preset")).toString().trimmed();
-    if (m_fontPreset.isEmpty())
-    {
-        QSettings legacyNim(QStringLiteral("NIM"), QStringLiteral("Client"));
-        m_fontPreset = legacyNim.value(QStringLiteral("ui/fontPreset")).toString();
-        if (m_fontPreset.isEmpty())
-        {
-            QSettings legacyIm(QStringLiteral("IM"), QStringLiteral("Client"));
-            m_fontPreset = legacyIm.value(QStringLiteral("ui/fontPreset"), QStringLiteral("system")).toString();
-        }
-        if (m_fontPreset.isEmpty())
-            m_fontPreset = QStringLiteral("system");
-        persist();
-    }
 
     const int x = ini.value(QStringLiteral("window/x"), -1).toInt();
     const int y = ini.value(QStringLiteral("window/y"), -1).toInt();
@@ -151,7 +129,7 @@ void UserSettings::saveLogin(const QString &account, const QString &nickname,
 
 void UserSettings::setFontPreset(const QString &key)
 {
-    m_fontPreset = key.isEmpty() ? QStringLiteral("system") : key;
+    m_fontPreset = key;
     persist();
 }
 

+ 1 - 1
Client/src/common/UserSettings.h

@@ -41,7 +41,7 @@ private:
     QString m_password;
     bool m_rememberPassword = false;
     bool m_autoLogin = false;
-    QString m_fontPreset = QStringLiteral("system");
+    QString m_fontPreset;
     QRect m_windowRect;
     bool m_windowMaximized = false;
     int m_closeAction = 0;

File diff suppressed because it is too large
+ 506 - 107
Client/src/mainframe/Client.cpp


+ 21 - 0
Client/src/mainframe/Client.h

@@ -4,6 +4,7 @@
 #include <QPixmap>
 #include <QJsonObject>
 #include <QSet>
+#include <QVector>
 
 class QShowEvent;
 class QCloseEvent;
@@ -54,6 +55,7 @@ private:
     QWidget *buildConversationPane();
     QWidget *buildChatPane();
     QWidget *buildFriendPane();
+    QWidget *buildFavoritePane();
     void refreshConvList();
     void refreshFriendList();
     void applyConvList(const QJsonArray &list);
@@ -85,6 +87,7 @@ private:
     void sendSticker(qint64 packId, const QString &code);
     void sendFile(bool imageOnly = false);
     void previewChatImage(qint64 fileId, const QString &name, const QString &content);
+    void favoritePayload(int msgType, const QString &content);
     void sendChatPayload(int msgType, const QString &content, const QString &preview);
     void sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
                            const QString &content, const QString &preview);
@@ -100,6 +103,12 @@ private:
     void setBubbleSendState(QWidget *row, bool failed, bool sending = false);
     void scrollChatToBottom();
     void clearBubbles();
+    void enterMsgSelectMode(qint64 preselectId = 0);
+    void exitMsgSelectMode();
+    void updateMsgSelectCount();
+    void deleteChatMessages(const QVector<qint64> &ids);
+    void applyDeletedMessages(const QVector<qint64> &ids);
+    void syncConvPreviewAfterDelete(qint64 peerUid);
     void upsertConv(qint64 peerUid, const QString &name, const QString &preview,
                     qint64 ts, int unread);
     void showSettings();
@@ -133,6 +142,8 @@ private:
     void showFromTray();
     void quitFromTray();
     void pollTrayHover();
+    void startUnreadAlert();
+    void stopUnreadAlert();
     int unreadTotal() const;
     QListWidgetItem *findConvItem(qint64 peerUid) const;
     QString previewOf(int msgType, const QString &content) const;
@@ -151,10 +162,13 @@ private:
 
     QPushButton *m_navChat = nullptr;
     QPushButton *m_navFriends = nullptr;
+    QPushButton *m_navFavorites = nullptr;
     QPushButton *m_navSettings = nullptr;
     QStackedWidget *m_sideStack = nullptr;
     QStackedWidget *m_convStack = nullptr;
     QStackedWidget *m_friendStack = nullptr;
+    class FavoritePanel *m_favPanel = nullptr;
+    class FavoriteDetailPage *m_favDetail = nullptr;
     QListWidget *m_convList = nullptr;
     QListWidget *m_friendList = nullptr;
     QLabel *m_convEmpty = nullptr;
@@ -167,6 +181,7 @@ private:
 
     QStackedWidget *m_chatStack = nullptr;
     QWidget *m_chatEmpty = nullptr;
+    QWidget *m_friendHintPage = nullptr;
     QWidget *m_chatBody = nullptr;
     QLabel *m_chatTitle = nullptr;
     QWidget *m_profilePage = nullptr;
@@ -186,9 +201,15 @@ private:
     QVBoxLayout *m_bubbleLay = nullptr;
     QScrollArea *m_scroll = nullptr;
     QTextEdit *m_input = nullptr;
+    QWidget *m_inputWrap = nullptr;
+    QWidget *m_msgSelectBar = nullptr;
+    QLabel *m_msgSelectHint = nullptr;
+    bool m_msgSelectMode = false;
     QSystemTrayIcon *m_tray = nullptr;
     QWidget *m_trayPreview = nullptr;
     QTimer *m_trayHover = nullptr;
+    QTimer *m_trayFlash = nullptr;
+    bool m_trayFlashLit = true;
     QTimer *m_geomSave = nullptr;
     QWidget *m_reconnectMask = nullptr;
     QWidget *m_reconnectSpin = nullptr;

+ 996 - 0
Client/src/mainframe/FavoritePanel.cpp

@@ -0,0 +1,996 @@
+#include "FavoritePanel.h"
+#include "AppConfig.h"
+#include "AvatarCache.h"
+#include "ConfirmDialog.h"
+#include "FileCache.h"
+#include "FileTypeIcon.h"
+#include "ImClient.h"
+#include "Protocol.h"
+#include "UiHelpers.h"
+
+#include <QApplication>
+#include <QDate>
+#include <QDateTime>
+#include <QDesktopServices>
+#include <QEvent>
+#include <QFileDialog>
+#include <QFileInfo>
+#include <QColor>
+#include <QFont>
+#include <QIcon>
+#include <QFrame>
+#include <QHBoxLayout>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QKeyEvent>
+#include <QKeySequence>
+#include <QShortcut>
+#include <QLabel>
+#include <QAbstractItemView>
+#include <QListView>
+#include <QListWidget>
+#include <QProgressBar>
+#include <QMenu>
+#include <QMimeDatabase>
+#include <QMouseEvent>
+#include <QPainter>
+#include <QPen>
+#include <QPixmap>
+#include <QPolygonF>
+#include <QPointer>
+#include <QPushButton>
+#include <QRegularExpression>
+#include <QSizePolicy>
+#include <QStackedWidget>
+#include <QTextCharFormat>
+#include <QTextCursor>
+#include <QTextDocument>
+#include <QTextEdit>
+#include <QTextImageFormat>
+#include <QTextList>
+#include <QUrl>
+#include <QVBoxLayout>
+
+namespace
+{
+    enum {
+        RoleId = Qt::UserRole,
+        RoleKind = Qt::UserRole + 1,
+        RoleTitle = Qt::UserRole + 2
+    };
+
+    QString htmlToPlain(const QString &raw)
+    {
+        if (raw.isEmpty())
+            return QString();
+        QString s = raw;
+        if (s.contains(QLatin1Char('<')) || s.contains(QLatin1Char('&')))
+        {
+            QTextDocument doc;
+            doc.setHtml(s);
+            s = doc.toPlainText();
+        }
+        s.remove(QRegularExpression(QStringLiteral(R"([^{}<\n]{0,48}\{[^}]{0,240}\})")));
+        return s.simplified();
+    }
+
+    QPixmap toolIcon(int kind, const QColor &color, int size = 16)
+    {
+        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+        QPixmap pm(qRound(size * dpr), qRound(size * dpr));
+        pm.fill(Qt::transparent);
+        pm.setDevicePixelRatio(dpr);
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.setPen(QPen(color, 1.4, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+        p.setBrush(Qt::NoBrush);
+        const qreal s = size;
+        if (kind == 0)
+        {
+            p.drawLine(QPointF(3.0, 4.2), QPointF(5.4, 4.2));
+            p.drawLine(QPointF(7.2, 4.2), QPointF(s - 2.4, 4.2));
+            p.drawLine(QPointF(3.0, 8.0), QPointF(5.4, 8.0));
+            p.drawLine(QPointF(7.2, 8.0), QPointF(s - 2.4, 8.0));
+            p.drawLine(QPointF(3.0, 11.8), QPointF(5.4, 11.8));
+            p.drawLine(QPointF(7.2, 11.8), QPointF(s - 2.4, 11.8));
+        }
+        else if (kind == 1)
+        {
+            p.drawRoundedRect(QRectF(2.0, 3.4, s - 4.0, s - 6.4), 1.6, 1.6);
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            p.drawEllipse(QRectF(4.2, 5.2, 2.4, 2.4));
+            QPolygonF hill;
+            hill << QPointF(2.8, s - 4.2) << QPointF(6.4, 8.4) << QPointF(9.0, 10.6)
+                 << QPointF(11.6, 7.6) << QPointF(s - 2.8, s - 4.2);
+            p.drawPolygon(hill);
+        }
+        else if (kind == 2)
+        {
+            p.drawRoundedRect(QRectF(4.0, 2.2, s - 7.2, s - 4.4), 1.1, 1.1);
+            p.drawLine(QPointF(4.0, 5.4), QPointF(s - 3.2, 5.4));
+            p.drawLine(QPointF(6.0, 8.0), QPointF(s - 5.6, 8.0));
+            p.drawLine(QPointF(6.0, 10.4), QPointF(s - 5.6, 10.4));
+        }
+        else if (kind == 3)
+        {
+            p.drawEllipse(QRectF(5.0, 2.4, s - 10.0, s - 10.0));
+            p.drawArc(QRectF(3.2, 8.6, s - 6.4, s - 7.0), 20 * 16, 140 * 16);
+        }
+        else
+        {
+            p.drawRoundedRect(QRectF(3.2, 2.2, s - 6.4, s - 4.2), 1.1, 1.1);
+            p.drawLine(QPointF(5.2, 5.6), QPointF(s - 5.2, 5.6));
+            p.drawLine(QPointF(5.2, 8.0), QPointF(s - 5.2, 8.0));
+            p.drawLine(QPointF(5.2, 10.4), QPointF(s - 6.4, 10.4));
+        }
+        return pm;
+    }
+
+    QPixmap kindIcon(int kind)
+    {
+        const QColor color(QStringLiteral("#07C160"));
+        if (kind == im::FavKind::Image)
+            return toolIcon(1, color, 16);
+        if (kind == im::FavKind::File)
+            return toolIcon(2, color, 16);
+        if (kind == im::FavKind::Card)
+            return toolIcon(3, color, 16);
+        return toolIcon(4, color, 16);
+    }
+
+    QString kindLabel(int kind)
+    {
+        switch (kind)
+        {
+        case im::FavKind::Image: return QStringLiteral("图片");
+        case im::FavKind::File:  return QStringLiteral("文件");
+        case im::FavKind::Card:  return QStringLiteral("名片");
+        case im::FavKind::Note:  return QStringLiteral("笔记");
+        default:                 return QStringLiteral("文本");
+        }
+    }
+
+    QString formatTs(qint64 ms)
+    {
+        if (ms <= 0)
+            return QString();
+        const QDateTime dt = QDateTime::fromMSecsSinceEpoch(ms);
+        if (dt.date() == QDate::currentDate())
+            return dt.toString(QStringLiteral("HH:mm"));
+        return dt.toString(QStringLiteral("MM-dd"));
+    }
+
+    QWidget *makeRow(int kind, const QString &title, qint64 ts, QWidget *parent)
+    {
+        auto *row = new QWidget(parent);
+        row->setMinimumWidth(0);
+        row->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
+        row->setAttribute(Qt::WA_TranslucentBackground);
+        auto *lay = new QHBoxLayout(row);
+        lay->setContentsMargins(12, 8, 12, 8);
+        lay->setSpacing(8);
+        auto *tag = new QLabel(row);
+        tag->setFixedSize(18, 18);
+        tag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+        tag->setPixmap(kindIcon(kind));
+        tag->setStyleSheet(QStringLiteral("background:transparent;"));
+        auto *name = new UiHelpers::ElideLabel(title.isEmpty() ? QStringLiteral("(无标题)") : title, row);
+        name->setStyleSheet(QStringLiteral(
+            "color:#191919; font-size:13px; background:transparent;"));
+        const QString tsText = formatTs(ts);
+        auto *time = new QLabel(tsText, row);
+        time->setStyleSheet(QStringLiteral(
+            "color:#B0B0B0; font-size:11px; background:transparent;"));
+        time->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
+        time->setMinimumWidth(time->fontMetrics().horizontalAdvance(
+            tsText.isEmpty() ? QStringLiteral("00:00") : tsText));
+        lay->addWidget(tag, 0, Qt::AlignVCenter);
+        lay->addWidget(name, 1, Qt::AlignVCenter);
+        lay->addWidget(time, 0, Qt::AlignVCenter);
+        return row;
+    }
+
+    QVector<qint64> favFileIds(const QString &html)
+    {
+        QVector<qint64> ids;
+        QRegularExpression re(QStringLiteral("favfile:(\\d+)"));
+        auto it = re.globalMatch(html);
+        while (it.hasNext())
+        {
+            const qint64 id = it.next().captured(1).toLongLong();
+            if (id > 0 && !ids.contains(id))
+                ids.push_back(id);
+        }
+        return ids;
+    }
+
+    class FavFileCard : public QWidget
+    {
+    public:
+        FavFileCard(QWidget *parent, qint64 fileId, const QString &name, qint64 size,
+                    const QString &url, const QString &mime)
+            : QWidget(parent)
+            , m_fileId(fileId)
+            , m_size(size)
+            , m_url(url)
+            , m_mime(mime)
+            , m_name(name)
+        {
+            setStyleSheet(QStringLiteral("background:#FFFFFF; border-radius:4px;"));
+            setFixedWidth(320);
+            FileCache::instance().bindMeta(fileId, url, mime, name, size);
+
+            auto *root = new QVBoxLayout(this);
+            root->setContentsMargins(12, 10, 12, 10);
+            root->setSpacing(6);
+
+            auto *top = new QHBoxLayout();
+            top->setContentsMargins(0, 0, 0, 0);
+            top->setSpacing(8);
+
+            auto *icon = new QLabel(this);
+            icon->setFixedSize(40, 40);
+            icon->setPixmap(FileTypeIcon::pixmap(name, 40));
+
+            auto *info = new QVBoxLayout();
+            info->setContentsMargins(0, 0, 0, 0);
+            info->setSpacing(2);
+            auto *n = new UiHelpers::ElideLabel(name, this);
+            n->setStyleSheet(QStringLiteral(
+                "color:#191919; font-size:13px; background:transparent;"));
+            m_statusLab = new QLabel(this);
+            m_statusLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent;"));
+            info->addWidget(n);
+            info->addWidget(m_statusLab);
+
+            m_action = UiHelpers::ghostButton(QStringLiteral("下载"), this);
+            m_action->setFixedSize(64, 26);
+            m_action->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+
+            top->addWidget(icon, 0, Qt::AlignTop);
+            top->addLayout(info, 1);
+            top->addWidget(m_action, 0, Qt::AlignVCenter);
+
+            m_bar = new QProgressBar(this);
+            m_bar->setRange(0, 1000);
+            m_bar->setValue(0);
+            m_bar->setTextVisible(false);
+            m_bar->setToolTip(QString());
+            m_bar->setFixedHeight(6);
+            m_bar->setStyleSheet(QStringLiteral(
+                "QProgressBar { background:rgba(0,0,0,0.08); border:none; border-radius:3px; }"
+                "QProgressBar::chunk { background:#07C160; border-radius:3px; }"));
+
+            auto *meta = new QHBoxLayout();
+            meta->setContentsMargins(0, 0, 0, 0);
+            m_sizeLab = new QLabel(this);
+            m_sizeLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent;"));
+            m_speedLab = new QLabel(this);
+            m_speedLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent;"));
+            meta->addWidget(m_sizeLab, 1);
+            meta->addWidget(m_speedLab, 0, Qt::AlignRight);
+
+            root->addLayout(top);
+            root->addWidget(m_bar);
+            root->addLayout(meta);
+            m_bar->hide();
+            m_sizeLab->hide();
+            m_speedLab->hide();
+
+            connect(m_action, &QPushButton::clicked, this, [this]() { onAction(); });
+            connect(&FileCache::instance(), &FileCache::statusChanged, this, [this](qint64 id) {
+                if (id == m_fileId)
+                    syncUi();
+            });
+            connect(&FileCache::instance(), &FileCache::progress, this,
+                    [this](qint64 id, qint64, qint64, double) {
+                if (id == m_fileId)
+                    syncUi();
+            });
+            connect(&FileCache::instance(), &FileCache::failed, this,
+                    [this](qint64 id, const QString &error) {
+                if (id != m_fileId)
+                    return;
+                UiHelpers::showToast(window(),
+                                     error.isEmpty() ? QStringLiteral("下载失败") : error,
+                                     true);
+                syncUi();
+            });
+            connect(&FileCache::instance(), &FileCache::ready, this, [this](qint64 id) {
+                if (id == m_fileId)
+                    syncUi();
+            });
+            syncUi();
+        }
+
+    protected:
+        void mouseReleaseEvent(QMouseEvent *event) override
+        {
+            if (event->button() == Qt::LeftButton && rect().contains(event->pos()))
+            {
+                if (!m_action->geometry().contains(event->pos()))
+                    onAction();
+            }
+            QWidget::mouseReleaseEvent(event);
+        }
+
+    private:
+        void onAction()
+        {
+            const FileCache::Status st = FileCache::instance().status(m_fileId);
+            if (st == FileCache::Downloading)
+            {
+                FileCache::instance().cancelDownload(m_fileId);
+                return;
+            }
+            const QString path = FileCache::instance().localPath(m_fileId);
+            if (!path.isEmpty())
+            {
+                if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
+                    UiHelpers::showToast(window(), QStringLiteral("无法打开文件"), true);
+                return;
+            }
+            FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
+            syncUi();
+        }
+
+        void syncUi()
+        {
+            const FileCache::Status st = FileCache::instance().status(m_fileId);
+            qint64 rec = 0, tot = m_size;
+            double bps = 0;
+            FileCache::instance().progressOf(m_fileId, &rec, &tot, &bps);
+            if (tot <= 0)
+                tot = m_size;
+            const bool received = (st == FileCache::Received);
+            const bool transferring = (st == FileCache::Downloading);
+            const bool paused = (st == FileCache::NotReceived && rec > 0);
+
+            QString status = FileCache::statusText(st);
+            if (paused)
+                status = QStringLiteral("已暂停");
+            m_statusLab->setText(status);
+
+            if (transferring || paused)
+            {
+                m_bar->show();
+                m_sizeLab->show();
+                m_speedLab->show();
+                if (tot > 0)
+                    m_bar->setValue(
+                        static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
+                else
+                    m_bar->setValue(0);
+                m_sizeLab->setText(QStringLiteral("%1 / %2")
+                                       .arg(FileCache::formatSize(rec),
+                                            tot > 0 ? FileCache::formatSize(tot)
+                                                    : QStringLiteral("--")));
+                m_speedLab->setText(transferring ? FileCache::formatSpeed(bps)
+                                                 : QStringLiteral("已暂停"));
+            }
+            else
+            {
+                m_bar->hide();
+                m_sizeLab->hide();
+                m_speedLab->hide();
+            }
+
+            if (transferring)
+                m_action->setText(QStringLiteral("暂停"));
+            else if (received)
+                m_action->setText(QStringLiteral("打开"));
+            else
+                m_action->setText(QStringLiteral("下载"));
+        }
+
+        qint64 m_fileId = 0;
+        qint64 m_size = 0;
+        QString m_url;
+        QString m_mime;
+        QString m_name;
+        QLabel *m_statusLab = nullptr;
+        QProgressBar *m_bar = nullptr;
+        QLabel *m_sizeLab = nullptr;
+        QLabel *m_speedLab = nullptr;
+        QPushButton *m_action = nullptr;
+    };
+}
+
+FavoritePanel::FavoritePanel(QWidget *parent)
+    : QWidget(parent)
+{
+    setStyleSheet(QStringLiteral("background:#F5F5F5;"));
+    auto *lay = new QVBoxLayout(this);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
+
+    m_stack = new QStackedWidget(this);
+    m_list = new QListWidget(m_stack);
+    m_list->setFrameShape(QFrame::NoFrame);
+    m_list->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    m_list->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+    m_list->setResizeMode(QListView::Adjust);
+    m_list->setContextMenuPolicy(Qt::CustomContextMenu);
+    m_list->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#F5F5F5; border:none; outline:0; }"
+        "QListWidget::item { height:52px; padding:0; margin:0; border:none; }"
+        "QListWidget::item:hover { background:#EDEDED; }"
+        "QListWidget::item:selected { background:#C6C6C6; }"));
+    m_empty = new QLabel(QStringLiteral("暂无收藏\n可在聊天中右键收藏,或点右上角 + 新建笔记"), m_stack);
+    m_empty->setAlignment(Qt::AlignCenter);
+    m_empty->setStyleSheet(QStringLiteral(
+        "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
+    m_stack->addWidget(m_list);
+    m_stack->addWidget(m_empty);
+    lay->addWidget(m_stack, 1);
+
+    connect(m_list, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
+        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
+            return;
+        emit itemActivated(item->data(RoleId).toLongLong());
+    });
+    connect(m_list, &QListWidget::customContextMenuRequested, this, &FavoritePanel::onMenu);
+}
+
+void FavoritePanel::refresh()
+{
+    QPointer<FavoritePanel> self(this);
+    ImClient::instance().favoriteList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->m_list->clear();
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        for (const QJsonValue &v : list)
+        {
+            const QJsonObject o = v.toObject();
+            const qint64 id = Proto::jsonI64(o, QStringLiteral("id"));
+            const int kind = o.value(QStringLiteral("kind")).toInt();
+            const QString title = htmlToPlain(o.value(QStringLiteral("title")).toString());
+            const qint64 ts = Proto::jsonI64(o, QStringLiteral("ts"));
+            auto *item = new QListWidgetItem(self->m_list);
+            item->setData(RoleId, id);
+            item->setData(RoleKind, kind);
+            item->setData(RoleTitle, title);
+            item->setSizeHint(QSize(10, 52));
+            self->m_list->setItemWidget(item, makeRow(kind, title, ts, self->m_list));
+        }
+        self->applyFilter(QString());
+    });
+}
+
+void FavoritePanel::applyFilter(const QString &key)
+{
+    int visible = 0;
+    for (int i = 0; i < m_list->count(); ++i)
+    {
+        auto *item = m_list->item(i);
+        const bool hide = !key.isEmpty() &&
+            !item->data(RoleTitle).toString().contains(key, Qt::CaseInsensitive);
+        item->setHidden(hide);
+        if (!hide)
+            ++visible;
+    }
+    if (visible == 0)
+    {
+        m_empty->setText(key.isEmpty()
+                             ? QStringLiteral("暂无收藏\n可在聊天中右键收藏,或点右上角 + 新建笔记")
+                             : QStringLiteral("未找到相关收藏"));
+        m_stack->setCurrentWidget(m_empty);
+    }
+    else
+    {
+        m_stack->setCurrentWidget(m_list);
+    }
+}
+
+void FavoritePanel::onMenu(const QPoint &pos)
+{
+    auto *item = m_list->itemAt(pos);
+    if (!item)
+        return;
+    const qint64 id = item->data(RoleId).toLongLong();
+    QMenu menu;
+    QAction *del = menu.addAction(QStringLiteral("删除"));
+    if (menu.exec(m_list->viewport()->mapToGlobal(pos)) != del)
+        return;
+    if (!ConfirmDialog::ask(this, QStringLiteral("删除收藏"),
+                            QStringLiteral("删除后不可恢复,仅影响你的收藏夹")))
+        return;
+    QPointer<FavoritePanel> self(this);
+    ImClient::instance().favoriteDelete(id, [self, id](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->refresh();
+        emit self->itemDeleted(id);
+    });
+}
+
+FavoriteDetailPage::FavoriteDetailPage(QWidget *parent)
+    : QWidget(parent)
+{
+    setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *lay = new QVBoxLayout(this);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
+
+    auto *header = new QWidget(this);
+    header->setFixedHeight(56);
+    header->setStyleSheet(QStringLiteral("background:#F7F7F7; border-bottom:1px solid #E5E5E5;"));
+    auto *hLay = new QHBoxLayout(header);
+    hLay->setContentsMargins(16, 0, 16, 0);
+    m_title = new QLabel(QStringLiteral("收藏"), header);
+    m_title->setStyleSheet(QStringLiteral(
+        "font-size:15px; font-weight:bold; background:transparent;"));
+    hLay->addWidget(m_title);
+    lay->addWidget(header);
+
+    m_toolbar = new QWidget(this);
+    m_toolbar->setStyleSheet(QStringLiteral("background:#F7F7F7; border-bottom:1px solid #E5E5E5;"));
+    auto *tLay = new QHBoxLayout(m_toolbar);
+    tLay->setContentsMargins(12, 6, 12, 6);
+    tLay->setSpacing(6);
+    auto makeTool = [this](const QString &text) {
+        auto *b = UiHelpers::ghostButton(text, m_toolbar);
+        b->setFixedHeight(26);
+        b->setStyleSheet(b->styleSheet() + QStringLiteral("QPushButton { font-size:12px; padding:3px 8px; }"));
+        return b;
+    };
+    auto makeIconTool = [this](int kind, const QString &tip) {
+        auto *b = new QPushButton(m_toolbar);
+        b->setCursor(Qt::PointingHandCursor);
+        b->setFixedSize(28, 26);
+        b->setIconSize(QSize(16, 16));
+        b->setIcon(QIcon(toolIcon(kind, QColor("#1A1A1A"), 16)));
+        b->setStyleSheet(QStringLiteral(
+            "QPushButton { background:#FFFFFF; border:1px solid #D9D9D9; border-radius:2px; padding:0; }"
+            "QPushButton:hover { background:#F5F5F5; }"));
+        return b;
+    };
+    auto *bold = makeTool(QStringLiteral("B"));
+    auto *italic = makeTool(QStringLiteral("I"));
+    auto *list = makeIconTool(0, QStringLiteral("列表"));
+    auto *img = makeIconTool(1, QStringLiteral("图片"));
+    auto *file = makeIconTool(2, QStringLiteral("文件"));
+    auto *save = UiHelpers::primaryButton(QStringLiteral("保存"), m_toolbar);
+    save->setFixedSize(72, 26);
+    save->setStyleSheet(save->styleSheet() + QStringLiteral(
+        "QPushButton { font-size:14px; padding:2px 10px; }"));
+    tLay->addWidget(bold);
+    tLay->addWidget(italic);
+    tLay->addWidget(list);
+    tLay->addWidget(img);
+    tLay->addWidget(file);
+    tLay->addStretch();
+    tLay->addWidget(save);
+    m_toolbar->hide();
+    lay->addWidget(m_toolbar);
+
+    m_body = new QWidget(this);
+    auto *bodyLay = new QVBoxLayout(m_body);
+    bodyLay->setContentsMargins(24, 24, 24, 24);
+    lay->addWidget(m_body, 1);
+
+    connect(bold, &QPushButton::clicked, this, [this]() {
+        if (!m_editor)
+            return;
+        QTextCharFormat fmt;
+        fmt.setFontWeight(m_editor->fontWeight() == QFont::Bold ? QFont::Normal : QFont::Bold);
+        m_editor->mergeCurrentCharFormat(fmt);
+    });
+    connect(italic, &QPushButton::clicked, this, [this]() {
+        if (!m_editor)
+            return;
+        QTextCharFormat fmt;
+        fmt.setFontItalic(!m_editor->fontItalic());
+        m_editor->mergeCurrentCharFormat(fmt);
+    });
+    connect(list, &QPushButton::clicked, this, [this]() {
+        if (!m_editor)
+            return;
+        QTextCursor c = m_editor->textCursor();
+        QTextListFormat lf;
+        lf.setStyle(QTextListFormat::ListDisc);
+        c.createList(lf);
+    });
+    connect(img, &QPushButton::clicked, this, &FavoriteDetailPage::insertImage);
+    connect(file, &QPushButton::clicked, this, &FavoriteDetailPage::insertFile);
+    connect(save, &QPushButton::clicked, this, &FavoriteDetailPage::saveNote);
+
+    auto *saveSc = new QShortcut(QKeySequence::Save, this);
+    saveSc->setContext(Qt::WidgetWithChildrenShortcut);
+    connect(saveSc, &QShortcut::activated, this, [this]() {
+        if (m_editor && m_toolbar && m_toolbar->isVisible())
+            saveNote();
+    });
+
+    showEmpty();
+}
+
+void FavoriteDetailPage::clearBody()
+{
+    if (auto *lay = m_body->layout())
+    {
+        while (QLayoutItem *it = lay->takeAt(0))
+        {
+            if (it->widget())
+                it->widget()->deleteLater();
+            delete it;
+        }
+    }
+    m_editor = nullptr;
+}
+
+void FavoriteDetailPage::showEmpty()
+{
+    m_id = 0;
+    m_kind = 0;
+    m_content.clear();
+    m_title->setText(QStringLiteral("收藏"));
+    m_toolbar->hide();
+    clearBody();
+    auto *hint = new QLabel(QStringLiteral("选择一条收藏,或点右上角 + 新建笔记"), m_body);
+    hint->setAlignment(Qt::AlignCenter);
+    hint->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:14px; background:transparent;"));
+    m_body->layout()->addWidget(hint);
+}
+
+void FavoriteDetailPage::load(qint64 id)
+{
+    if (id <= 0)
+        return;
+    QPointer<FavoriteDetailPage> self(this);
+    ImClient::instance().favoriteGet(id, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->render(reply);
+    });
+}
+
+void FavoriteDetailPage::startNewNote()
+{
+    m_id = 0;
+    m_kind = im::FavKind::Note;
+    m_content.clear();
+    m_title->setText(QStringLiteral("新建笔记"));
+    renderNote(QJsonObject(), true);
+}
+
+void FavoriteDetailPage::render(const QJsonObject &item)
+{
+    m_id = Proto::jsonI64(item, QStringLiteral("id"));
+    m_kind = item.value(QStringLiteral("kind")).toInt();
+    m_content = item.value(QStringLiteral("content")).toString();
+    const QString title = htmlToPlain(item.value(QStringLiteral("title")).toString());
+    m_title->setText(title.isEmpty() ? kindLabel(m_kind) : title);
+    m_toolbar->setVisible(m_kind == im::FavKind::Note);
+
+    if (m_kind == im::FavKind::Note)
+    {
+        renderNote(item, true);
+        return;
+    }
+
+    clearBody();
+    auto *lay = static_cast<QVBoxLayout *>(m_body->layout());
+    if (m_kind == im::FavKind::Text)
+    {
+        auto *lab = new QLabel(m_content, m_body);
+        lab->setWordWrap(true);
+        lab->setTextInteractionFlags(Qt::TextSelectableByMouse);
+        lab->setStyleSheet(QStringLiteral(
+            "color:#191919; font-size:14px; background:#FFFFFF; padding:12px; border-radius:4px;"));
+        lay->addWidget(lab);
+        lay->addStretch();
+        return;
+    }
+
+    const QJsonObject o = QJsonDocument::fromJson(m_content.toUtf8()).object();
+    if (m_kind == im::FavKind::Card)
+    {
+        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+        QString name = o.value(QStringLiteral("nickname")).toString();
+        const QString acc = o.value(QStringLiteral("account")).toString();
+        if (name.isEmpty())
+            name = acc;
+        const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *box = new QWidget(m_body);
+        box->setStyleSheet(QStringLiteral("background:#FFFFFF; border-radius:4px;"));
+        box->setCursor(Qt::PointingHandCursor);
+        auto *h = new QHBoxLayout(box);
+        h->setContentsMargins(12, 10, 12, 10);
+        auto *av = new QLabel(box);
+        av->setFixedSize(48, 48);
+        av->setPixmap(AvatarCache::instance().pixmap(uid, ver, name, 48));
+        auto *info = new QVBoxLayout();
+        auto *n = new QLabel(name, box);
+        n->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
+        auto *a = new QLabel(QStringLiteral("账号  %1").arg(acc), box);
+        a->setStyleSheet(QStringLiteral("color:#888; font-size:12px; background:transparent;"));
+        info->addWidget(n);
+        info->addWidget(a);
+        h->addWidget(av);
+        h->addLayout(info, 1);
+        lay->addWidget(box, 0, Qt::AlignLeft);
+        lay->addStretch();
+        box->setObjectName(QStringLiteral("favCard"));
+        box->setProperty("cardUid", uid);
+        box->installEventFilter(this);
+        return;
+    }
+
+    const qint64 fileId = Proto::jsonI64(o, QStringLiteral("file_id"));
+    const QString name = o.value(QStringLiteral("name")).toString();
+    const QString mime = o.value(QStringLiteral("mime")).toString();
+    const QString url = o.value(QStringLiteral("url")).toString();
+    const qint64 size = Proto::jsonI64(o, QStringLiteral("size"));
+    FileCache::instance().bindMeta(fileId, url, mime, name, size);
+
+    if (m_kind == im::FavKind::Image)
+    {
+        auto *img = new QLabel(m_body);
+        img->setAlignment(Qt::AlignCenter);
+        img->setCursor(Qt::PointingHandCursor);
+        img->setStyleSheet(QStringLiteral("background:#FFFFFF; border-radius:4px;"));
+        const QPixmap pm = FileCache::instance().thumbnail(fileId, 320);
+        if (pm.isNull())
+        {
+            img->setFixedSize(180, 120);
+            img->setText(QStringLiteral("加载中…"));
+            FileCache::instance().ensure(fileId, url, mime, name);
+            QPointer<QLabel> imgRef(img);
+            QPointer<FavoriteDetailPage> self(this);
+            connect(&FileCache::instance(), &FileCache::ready, img, [imgRef, self, fileId, name, o](qint64 id) {
+                if (!imgRef || id != fileId)
+                    return;
+                const QPixmap got = FileCache::instance().thumbnail(fileId, 320);
+                if (got.isNull())
+                    return;
+                imgRef->setText(QString());
+                imgRef->setPixmap(got);
+                const qreal dpr = qMax(1.0, got.devicePixelRatio());
+                imgRef->setFixedSize(qRound(got.width() / dpr), qRound(got.height() / dpr));
+            });
+        }
+        else
+        {
+            img->setPixmap(pm);
+            const qreal dpr = qMax(1.0, pm.devicePixelRatio());
+            img->setFixedSize(qRound(pm.width() / dpr), qRound(pm.height() / dpr));
+        }
+        img->installEventFilter(this);
+        img->setProperty("fileId", fileId);
+        img->setProperty("fileName", name);
+        lay->addWidget(img, 0, Qt::AlignLeft);
+        lay->addStretch();
+        return;
+    }
+
+    auto *card = new FavFileCard(m_body, fileId, name, size, url, mime);
+    lay->addWidget(card, 0, Qt::AlignLeft);
+    lay->addStretch();
+}
+
+bool FavoriteDetailPage::eventFilter(QObject *watched, QEvent *event)
+{
+    if (event->type() == QEvent::KeyPress)
+    {
+        auto *key = static_cast<QKeyEvent *>(event);
+        if (key->matches(QKeySequence::Save))
+        {
+            if (m_editor && m_toolbar && m_toolbar->isVisible())
+                saveNote();
+            return true;
+        }
+    }
+    if (event->type() == QEvent::MouseButtonRelease)
+    {
+        if (auto *w = qobject_cast<QWidget *>(watched))
+        {
+            if (w->objectName() == QStringLiteral("favCard"))
+            {
+                emit openCard(w->property("cardUid").toLongLong());
+                return true;
+            }
+            const qint64 fileId = w->property("fileId").toLongLong();
+            if (fileId > 0)
+            {
+                emit previewImage(fileId, w->property("fileName").toString(), m_content);
+                return true;
+            }
+        }
+    }
+    return QWidget::eventFilter(watched, event);
+}
+
+void FavoriteDetailPage::renderNote(const QJsonObject &item, bool editing)
+{
+    Q_UNUSED(editing);
+    clearBody();
+    m_toolbar->show();
+    auto *lay = static_cast<QVBoxLayout *>(m_body->layout());
+    m_editor = new QTextEdit(m_body);
+    m_editor->setAcceptRichText(true);
+    m_editor->installEventFilter(this);
+    m_editor->setStyleSheet(QStringLiteral(
+        "QTextEdit { background:#FFFFFF; border:1px solid #E5E5E5; padding:8px; }"));
+    QString html;
+    const QString raw = item.value(QStringLiteral("content")).toString();
+    const QJsonObject note = QJsonDocument::fromJson(raw.toUtf8()).object();
+    html = note.value(QStringLiteral("html")).toString();
+    if (html.isEmpty())
+        html = raw;
+    if (!html.isEmpty())
+        m_editor->setHtml(html);
+    lay->addWidget(m_editor, 1);
+    bindNoteImages(m_editor->toHtml());
+}
+
+void FavoriteDetailPage::bindNoteImages(const QString &html)
+{
+    if (!m_editor)
+        return;
+    const QVector<qint64> ids = favFileIds(html);
+    QPointer<QTextEdit> ed(m_editor);
+    for (qint64 id : ids)
+    {
+        const QPixmap pm = FileCache::instance().thumbnail(id, 240);
+        if (!pm.isNull())
+        {
+            m_editor->document()->addResource(QTextDocument::ImageResource,
+                                              QUrl(QStringLiteral("favfile:%1").arg(id)), pm);
+            continue;
+        }
+        FileCache::instance().ensure(id);
+        connect(&FileCache::instance(), &FileCache::ready, m_editor,
+                [ed, id](qint64 got) {
+            if (!ed || got != id)
+                return;
+            const QPixmap p = FileCache::instance().thumbnail(id, 240);
+            if (p.isNull())
+                return;
+            ed->document()->addResource(QTextDocument::ImageResource,
+                                        QUrl(QStringLiteral("favfile:%1").arg(id)), p);
+            ed->viewport()->update();
+        });
+    }
+}
+
+QString FavoriteDetailPage::noteContentJson() const
+{
+    QJsonObject o;
+    const QString html = m_editor ? m_editor->toHtml() : QString();
+    o.insert(QStringLiteral("html"), html);
+    QJsonArray files;
+    for (qint64 id : favFileIds(html))
+        files.append(id);
+    o.insert(QStringLiteral("files"), files);
+    return QString::fromUtf8(QJsonDocument(o).toJson(QJsonDocument::Compact));
+}
+
+void FavoriteDetailPage::saveNote()
+{
+    if (!m_editor || !m_toolbar || !m_toolbar->isVisible())
+        return;
+    const QString content = noteContentJson();
+    QPointer<FavoriteDetailPage> self(this);
+    auto done = [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->m_id = Proto::jsonI64(reply, QStringLiteral("id"));
+        UiHelpers::showToast(self, QStringLiteral("已保存"));
+        emit self->changed();
+    };
+    if (m_id > 0)
+        ImClient::instance().favoriteUpdate(m_id, content, done);
+    else
+    {
+        QJsonObject body;
+        body.insert(QStringLiteral("kind"), im::FavKind::Note);
+        body.insert(QStringLiteral("content"), content);
+        ImClient::instance().favoriteAdd(body, done);
+    }
+}
+
+void FavoriteDetailPage::insertImage()
+{
+    uploadAndInsert(QString(), true);
+}
+
+void FavoriteDetailPage::insertFile()
+{
+    uploadAndInsert(QString(), false);
+}
+
+void FavoriteDetailPage::uploadAndInsert(const QString &forcedPath, bool asImage)
+{
+    Q_UNUSED(forcedPath);
+    if (!m_editor)
+        return;
+    const QString filter = asImage
+        ? QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
+        : QString();
+    const QString path = QFileDialog::getOpenFileName(
+        this, asImage ? QStringLiteral("选择图片") : QStringLiteral("选择文件"), QString(), filter);
+    if (path.isEmpty())
+        return;
+    QFileInfo info(path);
+    if (info.size() <= 0)
+        return;
+    const qint64 fileMax = AppConfig::instance().fileMax();
+    if (fileMax > 0 && info.size() > fileMax)
+    {
+        UiHelpers::showToast(this, QStringLiteral("文件过大"), true);
+        return;
+    }
+    const QString mime = QMimeDatabase().mimeTypeForFile(path).name();
+    const QString name = info.fileName();
+    QPointer<FavoriteDetailPage> self(this);
+    ImClient::instance().putFile(name, mime, info.size(), QStringLiteral("favorite"),
+        [self, path, name, mime, asImage](const QJsonObject &reply) {
+            if (!self || !self->m_editor)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            const qint64 fileId = Proto::jsonI64(reply, QStringLiteral("file_id"));
+            const QString url = reply.value(QStringLiteral("url")).toString();
+            if (fileId <= 0)
+                return;
+            FileCache::instance().rememberPath(fileId, path, mime, name);
+            FileCache::instance().bindMeta(fileId, url, mime, name, QFileInfo(path).size());
+            auto *once = new QObject(self);
+            connect(&FileCache::instance(), &FileCache::uploaded, once,
+                    [self, once, fileId, name, asImage](qint64 id) {
+                if (id != fileId)
+                    return;
+                once->deleteLater();
+                if (!self || !self->m_editor)
+                    return;
+                QTextCursor c = self->m_editor->textCursor();
+                if (asImage)
+                {
+                    const QPixmap pm = FileCache::instance().thumbnail(fileId, 240);
+                    const QString key = QStringLiteral("favfile:%1").arg(fileId);
+                    if (!pm.isNull())
+                        self->m_editor->document()->addResource(
+                            QTextDocument::ImageResource, QUrl(key), pm);
+                    QTextImageFormat fmt;
+                    fmt.setName(key);
+                    fmt.setWidth(180);
+                    c.insertImage(fmt);
+                }
+                else
+                {
+                    c.insertHtml(QStringLiteral("<p><a href=\"favfile:%1\">%2</a></p>")
+                                     .arg(fileId).arg(name.toHtmlEscaped()));
+                }
+            });
+            FileCache::instance().startUpload(fileId, reply.value(QStringLiteral("token")).toObject());
+        });
+}

+ 74 - 0
Client/src/mainframe/FavoritePanel.h

@@ -0,0 +1,74 @@
+#pragma once
+
+#include <QJsonObject>
+#include <QWidget>
+
+class QEvent;
+class QLabel;
+class QListWidget;
+class QStackedWidget;
+class QTextEdit;
+class QPushButton;
+
+class FavoritePanel : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit FavoritePanel(QWidget *parent = nullptr);
+
+    void refresh();
+    void applyFilter(const QString &key);
+
+signals:
+    void itemActivated(qint64 id);
+    void createNoteRequested();
+    void itemDeleted(qint64 id);
+
+private:
+    void onMenu(const QPoint &pos);
+
+    QListWidget *m_list = nullptr;
+    QStackedWidget *m_stack = nullptr;
+    QLabel *m_empty = nullptr;
+};
+
+class FavoriteDetailPage : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit FavoriteDetailPage(QWidget *parent = nullptr);
+
+    void showEmpty();
+    void load(qint64 id);
+    void startNewNote();
+    qint64 currentId() const { return m_id; }
+
+signals:
+    void changed();
+    void openCard(qint64 uid);
+    void previewImage(qint64 fileId, const QString &name, const QString &content);
+
+private:
+    void clearBody();
+    void render(const QJsonObject &item);
+    void renderNote(const QJsonObject &item, bool editing);
+    void saveNote();
+    void insertImage();
+    void insertFile();
+    void uploadAndInsert(const QString &path, bool asImage);
+    QString noteContentJson() const;
+    void bindNoteImages(const QString &html);
+
+protected:
+    bool eventFilter(QObject *watched, QEvent *event) override;
+
+    QLabel *m_title = nullptr;
+    QWidget *m_body = nullptr;
+    QTextEdit *m_editor = nullptr;
+    QWidget *m_toolbar = nullptr;
+    qint64 m_id = 0;
+    int m_kind = 0;
+    QString m_content;
+};

BIN
Client/src/mainframe/SettingsDialog.cpp


+ 72 - 8
Client/src/network/ImClient.cpp

@@ -3,6 +3,7 @@
 #include "Logger.h"
 
 #include <QApplication>
+#include <QJsonArray>
 #include <QJsonDocument>
 #include <QJsonObject>
 #include <QStringList>
@@ -74,10 +75,9 @@ ImClient::ImClient(QObject *parent)
     : QObject(parent)
 {
     const auto &cfg = AppConfig::instance();
-    m_codec = new Proto::PacketCodec(cfg.magic(), cfg.maxPacket());
+    m_codec = new Proto::PacketCodec(cfg.magic());
     m_socket = new QTcpSocket(this);
     m_heartbeat = new QTimer(this);
-    m_heartbeat->setInterval(qMax(5, cfg.heartbeatSec()) * 1000);
 
     connect(m_socket, &QTcpSocket::connected, this, &ImClient::onConnected);
     connect(m_socket, &QTcpSocket::disconnected, this, &ImClient::onDisconnected);
@@ -323,6 +323,20 @@ void ImClient::deleteConv(qint64 peerUid, Reply cb)
     request(Proto::Type::Message, Proto::Cmd::DeleteConv, body, cb);
 }
 
+void ImClient::deleteMsgs(qint64 peerUid, const QVector<qint64> &ids, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("peer_uid"), peerUid);
+    QJsonArray arr;
+    for (qint64 id : ids)
+    {
+        if (id > 0)
+            arr.append(id);
+    }
+    body.insert(QStringLiteral("ids"), arr);
+    request(Proto::Type::Message, Proto::Cmd::DeleteMsg, body, cb);
+}
+
 void ImClient::setUnread(qint64 peerUid, int unread, Reply cb)
 {
     QJsonObject body;
@@ -374,11 +388,19 @@ void ImClient::getAvatar(qint64 uid, Reply cb)
 }
 
 void ImClient::putFile(const QString &name, const QString &mime, qint64 size, Reply cb)
+{
+    putFile(name, mime, size, QString(), cb);
+}
+
+void ImClient::putFile(const QString &name, const QString &mime, qint64 size, const QString &purpose,
+                       Reply cb)
 {
     QJsonObject body;
     body.insert(QStringLiteral("name"), name);
     body.insert(QStringLiteral("mime"), mime);
     body.insert(QStringLiteral("size"), size);
+    if (!purpose.isEmpty())
+        body.insert(QStringLiteral("purpose"), purpose);
     request(Proto::Type::Message, Proto::Cmd::PutFile, body, cb);
 }
 
@@ -389,6 +411,38 @@ void ImClient::getFile(qint64 fileId, Reply cb)
     request(Proto::Type::Message, Proto::Cmd::GetFile, body, cb, 60000);
 }
 
+void ImClient::favoriteAdd(const QJsonObject &body, Reply cb, int timeoutMs)
+{
+    request(Proto::Type::Favorite, Proto::Cmd::FavoriteAdd, body, cb, timeoutMs);
+}
+
+void ImClient::favoriteList(Reply cb)
+{
+    request(Proto::Type::Favorite, Proto::Cmd::FavoriteList, QJsonObject(), cb);
+}
+
+void ImClient::favoriteGet(qint64 id, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("id"), id);
+    request(Proto::Type::Favorite, Proto::Cmd::FavoriteGet, body, cb);
+}
+
+void ImClient::favoriteUpdate(qint64 id, const QString &content, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("id"), id);
+    body.insert(QStringLiteral("content"), content);
+    request(Proto::Type::Favorite, Proto::Cmd::FavoriteUpdate, body, cb);
+}
+
+void ImClient::favoriteDelete(qint64 id, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("id"), id);
+    request(Proto::Type::Favorite, Proto::Cmd::FavoriteDelete, body, cb);
+}
+
 void ImClient::chat(qint64 toUid, const QString &content, Reply cb)
 {
     chat(toUid, im::MsgType::Text, content, cb);
@@ -416,7 +470,7 @@ QString ImClient::messageOf(const QJsonObject &reply)
 
 void ImClient::request(Proto::Type type, Proto::Cmd cmd, const QJsonObject &body, Reply cb, int timeoutMs)
 {
-    // Login/Register 等不必等 SetLang。只有随后要拼 CDN 拉资源的命令需要等 limits/cdn。
+    // Login/Register 等不必等 SetConfig。只有随后要拼 CDN 拉资源的命令需要等 limits/cdn。
     const bool waitConfig = isConnected() && !m_configReady
         && (cmd == Proto::Cmd::GetAvatar || cmd == Proto::Cmd::GetFile
             || cmd == Proto::Cmd::GetSticker);
@@ -432,7 +486,7 @@ void ImClient::request(Proto::Type type, Proto::Cmd cmd, const QJsonObject &body
         if (!isConnected())
             connectToServer();
         else
-            LOG_DEBUG(QStringLiteral("queue %1/%2 waiting SetLang")
+            LOG_DEBUG(QStringLiteral("queue %1/%2 waiting SetConfig")
                           .arg(QLatin1String(Proto::typeName(static_cast<quint16>(type))))
                           .arg(QLatin1String(Proto::cmdName(static_cast<quint16>(cmd)))));
         return;
@@ -516,18 +570,17 @@ void ImClient::onConnected()
         QJsonObject body;
         const bool en = AppConfig::instance().lang() == im::i18n::Lang::En;
         body.insert(QStringLiteral("lang"), en ? QStringLiteral("en") : QStringLiteral("zh"));
-        request(Proto::Type::System, Proto::Cmd::SetLang, body, [this](const QJsonObject &reply) {
+        request(Proto::Type::System, Proto::Cmd::SetConfig, body, [this](const QJsonObject &reply) {
             if (ok(reply))
                 applyLimitsFromReply(reply);
             else
-                LOG_WARN(QStringLiteral("SetLang failed: %1").arg(messageOf(reply)));
+                LOG_WARN(QStringLiteral("SetConfig failed: %1").arg(messageOf(reply)));
             m_configReady = true;
             flushQueue();
         });
     }
-    // 连接过程中点的登录/注册立刻发出,不要卡在 SetLang 回包上
+    // 连接过程中点的登录/注册立刻发出,不要卡在 SetConfig 回包上
     flushQueue();
-    m_heartbeat->start();
     LOG_INFO(QStringLiteral("connected %1:%2")
                  .arg(m_socket->peerName())
                  .arg(m_socket->peerPort()));
@@ -632,7 +685,18 @@ void ImClient::applyLimitsFromReply(const QJsonObject &reply)
 {
     const QJsonObject limits = reply.value(QStringLiteral("limits")).toObject();
     if (!limits.isEmpty())
+    {
         AppConfig::instance().applyServerLimits(limits);
+        const auto &cfg = AppConfig::instance();
+        if (m_codec && cfg.maxPacket() > 0)
+            m_codec->setMaxPacket(cfg.maxPacket());
+        if (m_heartbeat && cfg.heartbeatSec() > 0)
+        {
+            m_heartbeat->setInterval(cfg.heartbeatSec() * 1000);
+            if (isConnected() && !m_heartbeat->isActive())
+                m_heartbeat->start();
+        }
+    }
     const QJsonObject cdn = reply.value(QStringLiteral("cdn")).toObject();
     if (!cdn.isEmpty())
         AppConfig::instance().applyServerCdn(cdn);

+ 8 - 0
Client/src/network/ImClient.h

@@ -58,6 +58,7 @@ public:
     void convList(Reply cb);
     void msgHistory(qint64 peerUid, qint64 lastId, int limit, Reply cb);
     void deleteConv(qint64 peerUid, Reply cb);
+    void deleteMsgs(qint64 peerUid, const QVector<qint64> &ids, Reply cb);
     void setUnread(qint64 peerUid, int unread, Reply cb);
     void deleteFriend(qint64 uid, Reply cb);
     void setRemark(qint64 uid, const QString &remark, Reply cb);
@@ -66,7 +67,14 @@ public:
                     Reply cb);
     void getAvatar(qint64 uid, Reply cb);
     void putFile(const QString &name, const QString &mime, qint64 size, Reply cb);
+    void putFile(const QString &name, const QString &mime, qint64 size, const QString &purpose,
+                 Reply cb);
     void getFile(qint64 fileId, Reply cb);
+    void favoriteAdd(const QJsonObject &body, Reply cb, int timeoutMs = 15000);
+    void favoriteList(Reply cb);
+    void favoriteGet(qint64 id, Reply cb);
+    void favoriteUpdate(qint64 id, const QString &content, Reply cb);
+    void favoriteDelete(qint64 id, Reply cb);
     void chat(qint64 toUid, const QString &content, Reply cb);
     void chat(qint64 toUid, int msgType, const QString &content, Reply cb);
     void applyLocalProfile(const QString &nickname, const QString &signature, int avatarVer);

+ 7 - 1
Client/src/network/Protocol.cpp

@@ -38,10 +38,16 @@ namespace Proto
 
     PacketCodec::PacketCodec(quint16 magic, quint32 maxPacket)
         : m_magic(magic)
-        , m_maxPacket(maxPacket)
+        , m_maxPacket(maxPacket >= 1024 ? maxPacket : 1048576)
     {
     }
 
+    void PacketCodec::setMaxPacket(quint32 maxPacket)
+    {
+        if (maxPacket >= 1024)
+            m_maxPacket = maxPacket;
+    }
+
     QByteArray PacketCodec::encode(quint16 type, quint16 cmd, quint32 seq, const QByteArray &body) const
     {
         QByteArray out;

+ 3 - 1
Client/src/network/Protocol.h

@@ -50,7 +50,9 @@ namespace Proto
     class PacketCodec
     {
     public:
-        PacketCodec(quint16 magic, quint32 maxPacket);
+        PacketCodec(quint16 magic, quint32 maxPacket = 1048576);
+
+        void setMaxPacket(quint32 maxPacket);
 
         QByteArray encode(quint16 type, quint16 cmd, quint32 seq, const QByteArray &body) const;
         DecodeStatus feed(const QByteArray &chunk, QVector<Packet> *out);

+ 73 - 0
Client/src/widgets/ConfirmDialog.cpp

@@ -2,13 +2,86 @@
 #include "FramelessHelper.h"
 #include "UiHelpers.h"
 
+#include <QCloseEvent>
 #include <QFrame>
 #include <QHBoxLayout>
+#include <QKeyEvent>
 #include <QLabel>
 #include <QMouseEvent>
 #include <QPushButton>
 #include <QVBoxLayout>
 
+WaitDialog::WaitDialog(QWidget *parent, const QString &title, const QString &text)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(title);
+    setFixedWidth(300);
+    UiHelpers::setupFramelessDialog(this);
+    setWindowModality(Qt::ApplicationModal);
+    new FramelessHelper(this, nullptr, false);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *body = new QWidget(this);
+    auto *bodyLay = new QVBoxLayout(body);
+    bodyLay->setContentsMargins(28, 32, 28, 32);
+    bodyLay->setSpacing(10);
+
+    auto *titleLab = new QLabel(title, body);
+    titleLab->setAlignment(Qt::AlignCenter);
+    titleLab->setStyleSheet(QStringLiteral(
+        "font-size:16px; color:#191919; font-weight:bold; background:transparent; border:none;"));
+
+    auto *msg = new QLabel(text, body);
+    msg->setWordWrap(true);
+    msg->setAlignment(Qt::AlignCenter);
+    msg->setStyleSheet(QStringLiteral(
+        "font-size:14px; color:#888888; background:transparent; border:none;"));
+
+    bodyLay->addWidget(titleLab);
+    bodyLay->addWidget(msg);
+    root->addWidget(body);
+
+    adjustSize();
+    setFixedHeight(height());
+}
+
+void WaitDialog::finish()
+{
+    if (m_done)
+        return;
+    m_done = true;
+    accept();
+}
+
+void WaitDialog::closeEvent(QCloseEvent *event)
+{
+    if (!m_done)
+    {
+        event->ignore();
+        return;
+    }
+    QDialog::closeEvent(event);
+}
+
+void WaitDialog::keyPressEvent(QKeyEvent *event)
+{
+    if (!m_done && (event->key() == Qt::Key_Escape || event->key() == Qt::Key_Return
+                    || event->key() == Qt::Key_Enter))
+        return;
+    QDialog::keyPressEvent(event);
+}
+
+void WaitDialog::reject()
+{
+    if (!m_done)
+        return;
+    QDialog::reject();
+}
+
 ConfirmDialog::ConfirmDialog(QWidget *parent, const QString &title, const QString &text,
                              const QString &okText, const QString &cancelText,
                              bool dangerOk, bool choiceMode)

+ 17 - 0
Client/src/widgets/ConfirmDialog.h

@@ -4,8 +4,25 @@
 #include <QPoint>
 #include <QString>
 
+class QCloseEvent;
+class QKeyEvent;
 class QMouseEvent;
 
+class WaitDialog : public QDialog
+{
+public:
+    WaitDialog(QWidget *parent, const QString &title, const QString &text);
+    void finish();
+
+protected:
+    void closeEvent(QCloseEvent *event) override;
+    void keyPressEvent(QKeyEvent *event) override;
+    void reject() override;
+
+private:
+    bool m_done = false;
+};
+
 class ConfirmDialog : public QDialog
 {
 public:

+ 11 - 1
Client/src/widgets/ImagePreviewDialog.cpp

@@ -13,7 +13,8 @@
 #include <QVBoxLayout>
 
 ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name,
-                                       const QByteArray &bytes, QWidget *parent, bool canForward)
+                                       const QByteArray &bytes, QWidget *parent, bool canForward,
+                                       bool canFavorite)
     : QDialog(parent)
 {
     setObjectName(QStringLiteral("ChromeWindow"));
@@ -67,11 +68,16 @@ ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name
     auto *fwd = UiHelpers::ghostButton(QStringLiteral("转发"), btns);
     fwd->setFixedSize(88, 32);
     fwd->setVisible(canForward);
+    auto *fav = UiHelpers::ghostButton(QStringLiteral("收藏"), btns);
+    fav->setFixedSize(88, 32);
+    fav->setVisible(canFavorite);
     auto *ok = UiHelpers::primaryButton(QStringLiteral("关闭"), btns);
     ok->setFixedSize(88, 32);
     bLay->addWidget(save);
     if (canForward)
         bLay->addWidget(fwd);
+    if (canFavorite)
+        bLay->addWidget(fav);
     bLay->addStretch();
     bLay->addWidget(ok);
     root->addWidget(btns);
@@ -81,6 +87,10 @@ ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name
         m_forward = true;
         accept();
     });
+    connect(fav, &QPushButton::clicked, this, [this]() {
+        m_favorite = true;
+        accept();
+    });
     connect(save, &QPushButton::clicked, this, [this, name, bytes]() {
         const QString path = QFileDialog::getSaveFileName(
             this, QStringLiteral("保存图片"), name.isEmpty() ? QStringLiteral("image.jpg") : name);

+ 3 - 1
Client/src/widgets/ImagePreviewDialog.h

@@ -9,10 +9,12 @@ class ImagePreviewDialog : public QDialog
 {
 public:
     ImagePreviewDialog(const QPixmap &image, const QString &name, const QByteArray &bytes,
-                       QWidget *parent = nullptr, bool canForward = false);
+                       QWidget *parent = nullptr, bool canForward = false, bool canFavorite = false);
 
     bool wantsForward() const { return m_forward; }
+    bool wantsFavorite() const { return m_favorite; }
 
 private:
     bool m_forward = false;
+    bool m_favorite = false;
 };

+ 5 - 1
common/i18n.h

@@ -8,7 +8,7 @@
 namespace im {
 namespace i18n {
 
-// 语言:由 Client 连接后 SetLang 下发,存于 Session;Client 本地 [app] lang 仅决定上报值与兜底文案
+// 语言:由 Client 连接后 SetConfig 下发,存于 Session;客户端写死上报值与兜底文案
 enum class Lang {
     Zh = 0,
     En = 1,
@@ -65,6 +65,8 @@ enum class Msg {
     AvatarInvalid,
     SignatureTooLong,
     CannotOperateSystemFriend,
+    FavoriteNotFound,
+    FavoriteContentTooLong,
     _Count
 };
 
@@ -133,6 +135,8 @@ inline const Entry& table(Msg id)
         {"头像无效", "Invalid avatar"},
         {"签名过长", "Signature too long"},
         {"系统账号不支持此操作", "System account does not support this action"},
+        {"收藏不存在", "Favorite not found"},
+        {"收藏内容过长", "Favorite content too long"},
     };
     const int i = static_cast<int>(id);
     if (i < 0 || i >= static_cast<int>(Msg::_Count)) {

+ 34 - 12
common/protocol_def.h

@@ -7,11 +7,12 @@ namespace im {
 // Client / Server 共用的协议常量。编解码、JSON、网络实现仍各放各工程。
 
 enum class Type : uint16_t {
-    System  = 1,  // 心跳、被踢等
-    Account = 2,  // 登录/注册/密码/隐私
-    Friend  = 3,  // 好友
-    Message = 4,  // 会话与聊天
-    Sticker = 5,  // 表情包
+    System   = 1,  // 心跳、被踢等
+    Account  = 2,  // 登录/注册/密码/隐私
+    Friend   = 3,  // 好友
+    Message  = 4,  // 会话与聊天
+    Sticker  = 5,  // 表情包
+    Favorite = 6,  // 收藏夹
 };
 
 enum class Cmd : uint16_t {
@@ -27,7 +28,7 @@ enum class Cmd : uint16_t {
     ConvList             = 10,
     MsgHistory           = 11,
     AddFriend            = 12,
-    SetLang              = 13,
+    SetConfig            = 13,
     SearchUser           = 14,
     FriendRequestList    = 15,
     HandleFriendRequest  = 16,
@@ -45,6 +46,12 @@ enum class Cmd : uint16_t {
     GetAvatar            = 28,
     PutFile              = 29,
     GetFile              = 30,
+    FavoriteAdd          = 31,
+    FavoriteList         = 32,
+    FavoriteGet          = 33,
+    FavoriteUpdate       = 34,
+    FavoriteDelete       = 35,
+    DeleteMsg            = 36,
     ChatNotify           = 100,
     Kicked               = 101,
     FriendRequestNotify  = 102,
@@ -75,6 +82,14 @@ constexpr int File    = 3;
 constexpr int Card    = 4;
 }
 
+namespace FavKind {
+constexpr int Text  = 1;
+constexpr int Image = 2;
+constexpr int File  = 3;
+constexpr int Card  = 4;
+constexpr int Note  = 5;
+}
+
 namespace FriendRel {
 constexpr int None     = 0;  // 非好友
 constexpr int Pending  = 1;  // 已发出申请,待对方同意
@@ -88,11 +103,12 @@ constexpr int64_t kFileHelperUid = 1;
 inline const char* type_name(uint16_t type)
 {
     switch (static_cast<Type>(type)) {
-    case Type::System:  return "System";
-    case Type::Account: return "Account";
-    case Type::Friend:  return "Friend";
-    case Type::Message: return "Message";
-    case Type::Sticker: return "Sticker";
+    case Type::System:   return "System";
+    case Type::Account:  return "Account";
+    case Type::Friend:   return "Friend";
+    case Type::Message:  return "Message";
+    case Type::Sticker:  return "Sticker";
+    case Type::Favorite: return "Favorite";
     }
     return "?";
 }
@@ -112,7 +128,7 @@ inline const char* cmd_name(uint16_t cmd)
     case Cmd::ConvList:             return "ConvList";
     case Cmd::MsgHistory:           return "MsgHistory";
     case Cmd::AddFriend:            return "AddFriend";
-    case Cmd::SetLang:              return "SetLang";
+    case Cmd::SetConfig:            return "SetConfig";
     case Cmd::SearchUser:           return "SearchUser";
     case Cmd::FriendRequestList:    return "FriendRequestList";
     case Cmd::HandleFriendRequest:  return "HandleFriendRequest";
@@ -130,6 +146,12 @@ inline const char* cmd_name(uint16_t cmd)
     case Cmd::GetAvatar:            return "GetAvatar";
     case Cmd::PutFile:              return "PutFile";
     case Cmd::GetFile:              return "GetFile";
+    case Cmd::FavoriteAdd:          return "FavoriteAdd";
+    case Cmd::FavoriteList:         return "FavoriteList";
+    case Cmd::FavoriteGet:          return "FavoriteGet";
+    case Cmd::FavoriteUpdate:       return "FavoriteUpdate";
+    case Cmd::FavoriteDelete:       return "FavoriteDelete";
+    case Cmd::DeleteMsg:            return "DeleteMsg";
     case Cmd::ChatNotify:           return "ChatNotify";
     case Cmd::Kicked:               return "Kicked";
     case Cmd::FriendRequestNotify:  return "FriendRequestNotify";

+ 92 - 18
docs/protocol.md

@@ -48,11 +48,11 @@ out += body   # UTF-8 bytes
 
 | Type | 值 | Handler | Cmd |
 |------|----|---------|-----|
-| System | 1 | 系统 | Heartbeat(1),SetLang(13)(应答带 `limits`),推送 Kicked(101) |
+| System | 1 | 系统 | Heartbeat(1),SetConfig(13)(应答带 `limits`),推送 Kicked(101) |
 | Account | 2 | 账户 | Login(2) Logout(3) Register(5) SendCode(6) ResetPassword(7) ChangePassword(8) GetPrivacy(17) SetPrivacy(18) SetProfile(27) GetAvatar(28) |
 | Friend | 3 | 好友 | FriendList(9) AddFriend(12) SearchUser(14) FriendRequestList(15) HandleFriendRequest(16) DeleteFriend(24) SetRemark(25) GetProfile(26),推送 FriendRequestNotify(102) FriendAcceptedNotify(103) |
-| Message | 4 | 消息 | Chat(4) ConvList(10) MsgHistory(11) DeleteConv(22) SetUnread(23) PutFile(29) GetFile(30),推送 ChatNotify(100) |
-| Sticker | 5 | 表情包 | StickerPackList(19) StickerPack(20) GetSticker(21) |
+| Message | 4 | 消息 | Chat(4) ConvList(10) MsgHistory(11) DeleteConv(22) SetUnread(23) PutFile(29) GetFile(30) DeleteMsg(36),推送 ChatNotify(100) |
+| Favorite | 6 | 收藏夹 | FavoriteAdd(31) FavoriteList(32) FavoriteGet(33) FavoriteUpdate(34) FavoriteDelete(35) |
 
 与 Client 界面的对应关系:
 
@@ -78,22 +78,28 @@ out += body   # UTF-8 bytes
 | 19 | StickerPackList | 是 | 聊天表情面板 | 表情包列表 |
 | 20 | StickerPack | 是 | 聊天表情面板 | 某包下表情元数据 |
 | 21 | GetSticker | 是 | 表情缓存 | 下载表情图片 |
-| 22 | DeleteConv | 是 | 会话右键 → 删除 | 删除自己的会话摘要,并永久删除双方聊天记录 |
+| 22 | DeleteConv | 是 | 会话右键 → 删除 | 删除自己的会话摘要,并标记自己侧消息已删除 |
 | 23 | SetUnread | 是 | 会话右键 → 已读/未读 | `unread=0` 已读,`unread=1` 未读 |
-| 24 | DeleteFriend | 是 | 好友右键 → 删除 | 双向解除好友,并永久删除双方聊天记录 |
+| 24 | DeleteFriend | 是 | 好友右键 → 删除 | 双向解除好友,并标记自己侧消息已删除 |
 | 25 | SetRemark | 是 | 好友右键 → 备注 | 只改自己这一侧的备注 |
 | 26 | GetProfile | 是 | 好友右键 → 查看名片 | 按 uid 拉资料 |
 | 27 | SetProfile | 是 | 设置 → 资料 | 改昵称、签名、头像 |
 | 28 | GetAvatar | 是 | 头像缓存 | 按 uid 下载头像 JPEG |
 | 29 | PutFile | 是 | 聊天 → 图片/文件 | 申请 COS 临时上传凭证,返回 file_id + token |
 | 30 | GetFile | 是 | 点击文件气泡 | 按 file_id 下载 |
+| 31 | FavoriteAdd | 是 | 消息右键 → 收藏 / 新建笔记 | 收藏会话内容或自定义笔记 |
+| 32 | FavoriteList | 是 | 左侧收藏夹 | 收藏列表 |
+| 33 | FavoriteGet | 是 | 点开一条收藏 | 收藏详情 |
+| 34 | FavoriteUpdate | 是 | 编辑笔记 | 只允许改文本/笔记 |
+| 35 | FavoriteDelete | 是 | 收藏右键 → 删除 | 删除自己的收藏及 COS 副本 |
+| 36 | DeleteMsg | 是 | 聊天记录右键 → 删除 / 多选删除 | 仅标记自己侧消息已删除 |
 | 100 | ChatNotify | 推送 | 聊天区收消息 | 对方在线时推送 |
 | 101 | Kicked | 推送 | 被挤下线 | 同一账号在别处登录 |
 | 102 | FriendRequestNotify | 推送 | 好友申请提示 | 收到加好友申请 |
 | 103 | FriendAcceptedNotify | 推送 | 好友同意提示 | 对方同意申请 |
 
-未登录可发:Heartbeat / SetLang / Login / Register / SendCode / ResetPassword。
-连接建立后客户端应立刻发送 SetLang,服务端按该连接语言返回 `msg`,并在应答中下发 `[limits]`(账号/密码长度、文件头像上限等)。客户端不再在本地 `config.ini` 保留这些限制。其余请求若未登录,回 `code=2`。
+未登录可发:Heartbeat / SetConfig / Login / Register / SendCode / ResetPassword。
+连接建立后客户端应立刻发送 SetConfig,服务端按该连接语言返回 `msg`,并在应答中下发 `[limits]`(账号/密码长度、文件头像上限等)。客户端不再在本地 `config.ini` 保留这些限制。其余请求若未登录,回 `code=2`。
 
 ## 3. 统一响应
 
@@ -145,15 +151,15 @@ out += body   # UTF-8 bytes
 | `ts` | number | 服务端毫秒时间戳 |
 
 
-### 4.1b SetLang(cmd=13)
+### 4.1b SetConfig(cmd=13)
 
-Type=`System`。连接成功后立即发送,设置本连接回包语言
+Type=`System`。连接成功后立即发送,上报客户端语言并拉取服务端配置
 
 请求:
 ```json
 {"lang":"zh"}
 ```
-`lang`:`zh` 或 `en`。
+`lang`:`zh` 或 `en`(决定本连接回包 `msg` 语言)
 
 成功:
 ```json
@@ -175,7 +181,10 @@ Type=`System`。连接成功后立即发送,设置本连接回包语言。
     "history_max":100,
     "avatar_max":81920,
     "file_max":524288,
-    "code_len":6
+    "code_len":6,
+    "max_packet":1048576,
+    "heartbeat_sec":30,
+    "interval_sec":60
   },
     "cdn":{
       "domain":"https://cdn.example.com"
@@ -188,7 +197,7 @@ Type=`System`。连接成功后立即发送,设置本连接回包语言。
 }
 ```
 
-`limits` 与服务端 `config.ini` 的 `[limits]` 以及 `[sms] code_len` 一致。客户端用其做输入长度和本地预校验;最终仍以服务端校验为准。
+`limits` 与服务端 `config.ini` 的 `[limits]`、`[protocol] max_packet`、`[client] heartbeat_sec`、`[sms] code_len` / `interval_sec` 一致。客户端用其做输入长度和本地预校验;最终仍以服务端校验为准。
 
 `cdn.domain` 只来自 `[cdn] domain`,不会按 COS 自动推导。未配置时下发空串,客户端不走 HTTP 下载。
 
@@ -381,13 +390,14 @@ GetSticker 成功:
       "last_msg_id":88,
       "preview":"hello",
       "ts":1788146838644,
-      "unread":2
+      "unread":2,
+      "online":1
     }
   ]
 }
 ```
 
-`unread` 对应会话头像角标。
+`unread` 对应会话头像角标。`online`:`1` 在线,`0` 离线。
 
 ### 4.11 MsgHistory(cmd=11)
 
@@ -417,7 +427,7 @@ GetSticker 成功:
 
 ### 4.12 Chat(cmd=4)
 
-对应输入框发送。`msg_type=1` 文本;`msg_type=2` 表情,`content` 为 `{"pack_id":1,"code":"smile"}`;`msg_type=3` 文件/图片,`content` 为 `{"file_id":12,"name":"a.pdf","size":1024,"mime":"application/pdf"}`(须先 PutFile,再用 token 直传 COS,对象落盘后再发 Chat;服务端会 HEAD 校验);`msg_type=4` 名片,`content` 为 `{"uid":10003,"account":"bob","nickname":"Bob"}`。文本最长、文件体积上限见服务端 `[limits] content_max` / `file_max`(连接后经 SetLang 下发给客户端)。
+对应输入框发送。`msg_type=1` 文本;`msg_type=2` 表情,`content` 为 `{"pack_id":1,"code":"smile"}`;`msg_type=3` 文件/图片,`content` 为 `{"file_id":12,"name":"a.pdf","size":1024,"mime":"application/pdf"}`(须先 PutFile,再用 token 直传 COS,对象落盘后再发 Chat;服务端会 HEAD 校验);`msg_type=4` 名片,`content` 为 `{"uid":10003,"account":"bob","nickname":"Bob"}`。文本最长、文件体积上限见服务端 `[limits] content_max` / `file_max`(连接后经 SetConfig 下发给客户端)。
 
 ```json
 {"to_uid":10003,"msg_type":1,"content":"hello"}
@@ -448,12 +458,22 @@ GetSticker 成功:
 
 ### 4.14 DeleteConv(cmd=22)
 
-删除当前用户的会话列表项,并 **永久删除** `im_message` 中双方之间的全部聊天记录。再次聊天会从空白开始。
+删除当前用户的会话列表项,并标记该用户侧的消息为已删除(对方聊天记录不受影响)。再次聊天会从空白开始。
 
 ```json
 {"peer_uid":10003}
 ```
 
+### 4.14b DeleteMsg(cmd=36)
+
+删除当前用户聊天记录中的若干条消息(软删除,对方仍可见)。`ids` 最多 100 个,且须属于该 `peer_uid` 会话。
+
+```json
+{"peer_uid":10003,"ids":[87,88]}
+```
+
+成功:`peer_uid`、`ids`、以及更新后的会话预览 `last_msg_id`、`preview`、`ts`(无剩余消息时为空/0)。
+
 ### 4.15 SetUnread(cmd=23)
 
 ```json
@@ -468,7 +488,7 @@ GetSticker 成功:
 {"uid":10003}
 ```
 
-删除 `im_friend` 双向关系,并永久删除双方聊天记录与会话摘要。不是好友时 `code=4`。
+删除 `im_friend` 双向关系,并标记当前用户侧消息为已删除、清除自己的会话摘要。对方聊天记录不受影响。不是好友时 `code=4`。
 
 ### 4.17 SetRemark(cmd=25)
 
@@ -524,6 +544,7 @@ GetSticker 成功:
 | `mime` | string | 可选,缺省 `application/octet-stream` |
 | `size` | number | **必填**,字节数,`1 … file_max` |
 | `sha256` | string | 可选,十六进制,最长 64 |
+| `purpose` | string | 可选。`favorite` 时对象键走 `{prefix}/favorites/{uid}/{file_id}`,供收藏笔记插附件 |
 
 成功:
 
@@ -579,6 +600,58 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 
 成功:`file_id`、`name`、`size`、`mime`、`data`(base64)。
 
+### 4.24 FavoriteAdd(cmd=31,Type=6)
+
+从会话收藏,或新建笔记。图片/文件会 **Copy** 到 COS `{prefix}/favorites/{uid}/{file_id}`,与聊天文件独立。
+
+会话收藏:
+
+```json
+{"msg_type":1,"content":"一段话","src_peer_uid":10003,"src_msg_id":88}
+```
+
+`msg_type`:1 文本、3 图片/文件(content 为 Chat 同款 JSON,含 `file_id`)、4 名片。
+
+新建笔记:
+
+```json
+{"kind":5,"content":"{\"html\":\"<p>笔记</p>\",\"files\":[12]}"}
+```
+
+成功:`id`、`kind`、`title`。`kind`:1文本 2图片 3文件 4名片 5笔记。
+
+### 4.25 FavoriteList(cmd=32)
+
+```json
+{"limit":200}
+```
+
+成功:`list` 数组,每项 `id`、`kind`、`title`、`ts`。
+
+### 4.26 FavoriteGet(cmd=33)
+
+```json
+{"id":1}
+```
+
+成功:`id`、`kind`、`title`、`content`、`src_msg_id`、`src_peer_uid`、`ts`、`updated_ts`。
+
+### 4.27 FavoriteUpdate(cmd=34)
+
+仅文本/笔记。笔记 `content` 格式同 FavoriteAdd。
+
+```json
+{"id":1,"content":"{\"html\":\"<p>改过了</p>\",\"files\":[12]}"}
+```
+
+### 4.28 FavoriteDelete(cmd=35)
+
+```json
+{"id":1}
+```
+
+只删当前用户这条收藏,以及其 `favorites/` 下的 COS 副本。聊天 `im/files/` 不动。
+
 ### 4.19 Kicked(cmd=101,推送)
 
 ```json
@@ -599,7 +672,7 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 
 客户端只关心 `[server]` 的 `host`/`port` 和 `[protocol]` 的 `magic`/`max_packet`。`magic` 为十六进制字符串(如 `0x494D`)。改 `magic` 后客户端必须同步。
 
-资源下载域名见 `[cdn] domain`,由 SetLang 原样下发;未配置则为空。COS 对象键前缀见 `[cos] prefix`。聊天图片/文件由客户端持 PutFile 下发的临时 token 直传 COS,字节不经过 IM 服务端。
+资源下载域名见 `[cdn] domain`,由 SetConfig 原样下发;未配置则为空。COS 对象键前缀见 `[cos] prefix`。聊天图片/文件由客户端持 PutFile 下发的临时 token 直传 COS,字节不经过 IM 服务端。
 
 长度、条数、验证码间隔等见 `[limits]`、`[sms]`。
 
@@ -614,6 +687,7 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 | `im_friend` | 双向好友(含 `remark` 备注) |
 | `im_message` | 消息 |
 | `im_conversation` | 会话摘要与未读 |
+| `im_favorite` | 收藏(文本/图片/文件/名片/笔记) |
 
 `uid=1` 为文件传输助手(`username=filehelper`,不可登录)。
 

+ 5 - 1
server/config/config.ini.example

@@ -12,6 +12,10 @@ magic=0x494D
 # 单包 body 最大字节。聊天图片/文件走 COS 直传,不再占用 TCP 包体。
 max_packet=1048576
 
+[client]
+# 客户端心跳间隔(秒),连接后经 SetConfig 下发
+heartbeat_sec=30
+
 [limits]
 # 账号最短长度(字母或数字)
 account_min=4
@@ -55,7 +59,7 @@ bucket=
 prefix=im
 
 [cdn]
-# 资源公网域名(无尾斜杠),SetLang 原样下发给客户端
+# 资源公网域名(无尾斜杠),SetConfig 原样下发给客户端
 # 客户端用 domain + "/" + url 拼 HTTP 下载地址
 # 必须填写;留空则下发空域名,客户端不走 HTTP 下载
 domain=

+ 16 - 0
server/sql/init.sql

@@ -46,6 +46,8 @@ CREATE TABLE IF NOT EXISTS im_message (
     to_uid      BIGINT UNSIGNED NOT NULL,
     msg_type    TINYINT         NOT NULL DEFAULT 1,
     content     TEXT            NOT NULL,
+    sender_deleted   TINYINT      NOT NULL DEFAULT 0 COMMENT '发送方已删除',
+    receiver_deleted TINYINT      NOT NULL DEFAULT 0 COMMENT '接收方已删除',
     created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
     PRIMARY KEY (id),
     KEY idx_to_uid (to_uid, id),
@@ -114,6 +116,20 @@ CREATE TABLE IF NOT EXISTS im_file (
     KEY idx_from (from_uid)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+CREATE TABLE IF NOT EXISTS im_favorite (
+    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    uid           BIGINT UNSIGNED NOT NULL,
+    kind          TINYINT         NOT NULL DEFAULT 1 COMMENT '1文本 2图片 3文件 4名片 5笔记',
+    title         VARCHAR(128)    NOT NULL DEFAULT '',
+    content       MEDIUMTEXT      NOT NULL,
+    src_msg_id    BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    src_peer_uid  BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_uid_time (uid, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
 -- 系统账号:文件传输助手。密码不可登录(status=2)
 INSERT INTO im_user (id, username, password, nickname, phone, signature, status)
 VALUES (1, 'filehelper', '-', '文件传输助手', '', '文件助手', 2)

+ 19 - 0
server/sql/migrate.sql

@@ -109,3 +109,22 @@ CREATE TABLE IF NOT EXISTS im_file (
 INSERT INTO im_sticker_pack (id, name, version, sort_order, status)
 VALUES (1, '默认表情', 1, 1, 1), (2, '手势', 1, 2, 1)
 ON DUPLICATE KEY UPDATE name=VALUES(name), status=VALUES(status);
+
+ALTER TABLE im_message
+    ADD COLUMN sender_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '发送方已删除' AFTER content;
+ALTER TABLE im_message
+    ADD COLUMN receiver_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '接收方已删除' AFTER sender_deleted;
+
+CREATE TABLE IF NOT EXISTS im_favorite (
+    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    uid           BIGINT UNSIGNED NOT NULL,
+    kind          TINYINT         NOT NULL DEFAULT 1 COMMENT '1文本 2图片 3文件 4名片 5笔记',
+    title         VARCHAR(128)    NOT NULL DEFAULT '',
+    content       MEDIUMTEXT      NOT NULL,
+    src_msg_id    BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    src_peer_uid  BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_uid_time (uid, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

+ 2 - 0
server/src/app/application.cpp

@@ -11,6 +11,7 @@
 #include "third/cos.h"
 #include "util/file.h"
 #include "im/handlers/account_handler.h"
+#include "im/handlers/favorite_handler.h"
 #include "im/handlers/friend_handler.h"
 #include "im/handlers/message_handler.h"
 #include "im/handlers/sticker_handler.h"
@@ -130,6 +131,7 @@ bool Application::init(int argc, char** argv)
     dispatcher_.add(Type::Friend, [] { return std::make_unique<FriendHandler>(); });
     dispatcher_.add(Type::Message, [] { return std::make_unique<MessageHandler>(); });
     dispatcher_.add(Type::Sticker, [] { return std::make_unique<StickerHandler>(); });
+    dispatcher_.add(Type::Favorite, [] { return std::make_unique<FavoriteHandler>(); });
 
     if (mysql_.started()) {
         StickerStore stickers(mysql_);

+ 8 - 0
server/src/config/app_config.cpp

@@ -100,6 +100,14 @@ bool AppConfig::load(const std::string& filepath)
     protocol_.magic = parse_magic(ini.read("protocol", "magic", "0x494D"), 0x494D);
     protocol_.max_packet =
         to_u32(ini.read("protocol", "max_packet", "1048576"), 1048576);
+    if (protocol_.max_packet < 1024) {
+        protocol_.max_packet = 1024;
+    }
+
+    client_.heartbeat_sec = to_u32(ini.read("client", "heartbeat_sec", "30"), 30);
+    if (client_.heartbeat_sec < 5) {
+        client_.heartbeat_sec = 5;
+    }
 
     mysql_.enable = ini.read("mysql", "enable", "1") != "0";
     mysql_.required = ini.read("mysql", "required", "0") == "1";

+ 6 - 0
server/src/config/app_config.h

@@ -18,6 +18,10 @@ struct ProtocolConfig {
     uint32_t max_packet = 1048576;
 };
 
+struct ClientConfig {
+    uint32_t heartbeat_sec = 30;
+};
+
 struct MysqlConfig {
     bool enable = true;
     bool required = false;
@@ -92,6 +96,7 @@ public:
 
     const ServerConfig& server() const { return server_; }
     const ProtocolConfig& protocol() const { return protocol_; }
+    const ClientConfig& client() const { return client_; }
     const MysqlConfig& mysql() const { return mysql_; }
     const SmsConfig& sms() const { return sms_; }
     const LimitsConfig& limits() const { return limits_; }
@@ -105,6 +110,7 @@ public:
 private:
     ServerConfig server_;
     ProtocolConfig protocol_;
+    ClientConfig client_;
     MysqlConfig mysql_;
     SmsConfig sms_;
     LimitsConfig limits_;

+ 138 - 0
server/src/db/favorite_store.cpp

@@ -0,0 +1,138 @@
+#include "db/favorite_store.h"
+
+#include <exception>
+
+#include "common/logger.h"
+
+namespace im {
+
+FavoriteStore::FavoriteStore(MysqlService& mysql) : mysql_(mysql) {}
+
+UserId FavoriteStore::insert(UserId uid, int kind, const std::string& title,
+                             const std::string& content, UserId src_msg_id, UserId src_peer_uid)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "INSERT INTO im_favorite (uid, kind, title, content, src_msg_id, src_peer_uid) "
+            "VALUES (?,?,?,?,?,?)");
+        stmt->set_int64(1, uid);
+        stmt->set_int32(2, kind);
+        stmt->set_string(3, title);
+        stmt->set_string(4, content);
+        stmt->set_int64(5, src_msg_id);
+        stmt->set_int64(6, src_peer_uid);
+        stmt->update();
+        return static_cast<UserId>(conn->insert_id());
+    } catch (const std::exception& e) {
+        Logger::error(std::string("favorite insert: ") + e.what());
+        throw;
+    }
+}
+
+bool FavoriteStore::update(UserId uid, UserId id, const std::string& title,
+                           const std::string& content)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_favorite SET title=?, content=? WHERE id=? AND uid=?");
+        stmt->set_string(1, title);
+        stmt->set_string(2, content);
+        stmt->set_int64(3, id);
+        stmt->set_int64(4, uid);
+        stmt->update();
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("favorite update: ") + e.what());
+        throw;
+    }
+}
+
+bool FavoriteStore::remove(UserId uid, UserId id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql("DELETE FROM im_favorite WHERE id=? AND uid=?");
+        stmt->set_int64(1, id);
+        stmt->set_int64(2, uid);
+        stmt->update();
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("favorite remove: ") + e.what());
+        throw;
+    }
+}
+
+bool FavoriteStore::find(UserId uid, UserId id, FavoriteRow& out)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT id, uid, kind, title, content, src_msg_id, src_peer_uid, "
+            "UNIX_TIMESTAMP(created_at) AS ts, UNIX_TIMESTAMP(updated_at) AS updated_ts "
+            "FROM im_favorite WHERE id=? AND uid=? LIMIT 1");
+        stmt->set_int64(1, id);
+        stmt->set_int64(2, uid);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return false;
+        }
+        out.id = static_cast<UserId>(rs->get_int64("id"));
+        out.uid = static_cast<UserId>(rs->get_int64("uid"));
+        out.kind = rs->get_int32("kind");
+        out.title = rs->get_string("title");
+        out.content = rs->get_string("content");
+        out.src_msg_id = static_cast<UserId>(rs->get_int64("src_msg_id"));
+        out.src_peer_uid = static_cast<UserId>(rs->get_int64("src_peer_uid"));
+        out.ts = rs->get_int64("ts") * 1000;
+        out.updated_ts = rs->get_int64("updated_ts") * 1000;
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("favorite find: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<FavoriteRow> FavoriteStore::list(UserId uid, int limit)
+{
+    std::vector<FavoriteRow> out;
+    if (limit <= 0) {
+        limit = 200;
+    }
+    if (limit > 500) {
+        limit = 500;
+    }
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT id, uid, kind, title, content, src_msg_id, src_peer_uid, "
+            "UNIX_TIMESTAMP(created_at) AS ts, UNIX_TIMESTAMP(updated_at) AS updated_ts "
+            "FROM im_favorite WHERE uid=? ORDER BY id DESC LIMIT ?");
+        stmt->set_int64(1, uid);
+        stmt->set_int32(2, limit);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            FavoriteRow row;
+            row.id = static_cast<UserId>(rs->get_int64("id"));
+            row.uid = static_cast<UserId>(rs->get_int64("uid"));
+            row.kind = rs->get_int32("kind");
+            row.title = rs->get_string("title");
+            row.content = rs->get_string("content");
+            row.src_msg_id = static_cast<UserId>(rs->get_int64("src_msg_id"));
+            row.src_peer_uid = static_cast<UserId>(rs->get_int64("src_peer_uid"));
+            row.ts = rs->get_int64("ts") * 1000;
+            row.updated_ts = rs->get_int64("updated_ts") * 1000;
+            out.push_back(std::move(row));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("favorite list: ") + e.what());
+        throw;
+    }
+}
+
+}  // namespace im

+ 38 - 0
server/src/db/favorite_store.h

@@ -0,0 +1,38 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include "common/types.h"
+#include "db/mysql_service.h"
+
+namespace im {
+
+struct FavoriteRow {
+    UserId id = 0;
+    UserId uid = 0;
+    int kind = 1;
+    std::string title;
+    std::string content;
+    UserId src_msg_id = 0;
+    UserId src_peer_uid = 0;
+    int64_t ts = 0;
+    int64_t updated_ts = 0;
+};
+
+class FavoriteStore {
+public:
+    explicit FavoriteStore(MysqlService& mysql);
+
+    UserId insert(UserId uid, int kind, const std::string& title, const std::string& content,
+                  UserId src_msg_id, UserId src_peer_uid);
+    bool update(UserId uid, UserId id, const std::string& title, const std::string& content);
+    bool remove(UserId uid, UserId id);
+    bool find(UserId uid, UserId id, FavoriteRow& out);
+    std::vector<FavoriteRow> list(UserId uid, int limit);
+
+private:
+    MysqlService& mysql_;
+};
+
+}  // namespace im

+ 14 - 0
server/src/db/file_store.cpp

@@ -70,4 +70,18 @@ bool FileStore::find(UserId id, FileRow& out)
     }
 }
 
+bool FileStore::remove(UserId id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql("DELETE FROM im_file WHERE id=?");
+        stmt->set_int64(1, id);
+        stmt->update();
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("file remove: ") + e.what());
+        throw;
+    }
+}
+
 }  // namespace im

+ 1 - 0
server/src/db/file_store.h

@@ -25,6 +25,7 @@ public:
                   const std::string& sha256, const std::string& file_path);
     bool set_path(UserId id, const std::string& file_path);
     bool find(UserId id, FileRow& out);
+    bool remove(UserId id);
 
 private:
     MysqlService& mysql_;

+ 122 - 8
server/src/db/message_store.cpp

@@ -81,19 +81,32 @@ void MessageStore::delete_conversation(UserId uid, UserId peer)
         auto conn = mysql_.acquire();
         conn->begin();
         try {
-            auto* msgs = conn->setsql(
-                "DELETE FROM im_message WHERE (from_uid=? AND to_uid=?) OR (from_uid=? AND to_uid=?)");
-            msgs->set_int64(1, uid);
-            msgs->set_int64(2, peer);
-            msgs->set_int64(3, peer);
-            msgs->set_int64(4, uid);
-            msgs->update();
+            auto* sent = conn->setsql(
+                "UPDATE im_message SET sender_deleted=1 WHERE from_uid=? AND to_uid=?");
+            sent->set_int64(1, uid);
+            sent->set_int64(2, peer);
+            sent->update();
+
+            auto* recv = conn->setsql(
+                "UPDATE im_message SET receiver_deleted=1 WHERE from_uid=? AND to_uid=?");
+            recv->set_int64(1, peer);
+            recv->set_int64(2, uid);
+            recv->update();
 
             auto* conv = conn->setsql(
                 "DELETE FROM im_conversation WHERE uid=? AND peer_uid=?");
             conv->set_int64(1, uid);
             conv->set_int64(2, peer);
             conv->update();
+
+            auto* purge = conn->setsql(
+                "DELETE FROM im_message WHERE sender_deleted=1 AND receiver_deleted=1 "
+                "AND ((from_uid=? AND to_uid=?) OR (from_uid=? AND to_uid=?))");
+            purge->set_int64(1, uid);
+            purge->set_int64(2, peer);
+            purge->set_int64(3, peer);
+            purge->set_int64(4, uid);
+            purge->update();
             conn->commit();
         } catch (...) {
             conn->rollback();
@@ -105,6 +118,106 @@ void MessageStore::delete_conversation(UserId uid, UserId peer)
     }
 }
 
+int MessageStore::delete_messages(UserId uid, UserId peer, const std::vector<UserId>& ids)
+{
+    if (uid <= 0 || peer <= 0 || ids.empty()) {
+        return 0;
+    }
+    std::string in;
+    int n = 0;
+    for (UserId id : ids) {
+        if (id <= 0) {
+            continue;
+        }
+        if (!in.empty()) {
+            in += ',';
+        }
+        in += std::to_string(static_cast<unsigned long long>(id));
+        ++n;
+    }
+    if (in.empty()) {
+        return 0;
+    }
+    try {
+        auto conn = mysql_.acquire();
+        auto* sent = conn->setsql(
+            "UPDATE im_message SET sender_deleted=1 "
+            "WHERE from_uid=? AND to_uid=? AND sender_deleted=0 AND id IN (" + in + ")");
+        sent->set_int64(1, uid);
+        sent->set_int64(2, peer);
+        sent->update();
+
+        auto* recv = conn->setsql(
+            "UPDATE im_message SET receiver_deleted=1 "
+            "WHERE from_uid=? AND to_uid=? AND receiver_deleted=0 AND id IN (" + in + ")");
+        recv->set_int64(1, peer);
+        recv->set_int64(2, uid);
+        recv->update();
+
+        auto* purge = conn->setsql(
+            "DELETE FROM im_message WHERE sender_deleted=1 AND receiver_deleted=1 "
+            "AND ((from_uid=? AND to_uid=?) OR (from_uid=? AND to_uid=?)) "
+            "AND id IN (" + in + ")");
+        purge->set_int64(1, uid);
+        purge->set_int64(2, peer);
+        purge->set_int64(3, peer);
+        purge->set_int64(4, uid);
+        purge->update();
+        return n;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("delete_messages: ") + e.what());
+        throw;
+    }
+}
+
+bool MessageStore::latest_visible(UserId uid, UserId peer, MsgRow& out)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT id, from_uid, to_uid, msg_type, content, UNIX_TIMESTAMP(created_at) AS ts "
+            "FROM im_message WHERE ((from_uid=? AND to_uid=? AND sender_deleted=0) "
+            "OR (from_uid=? AND to_uid=? AND receiver_deleted=0)) "
+            "ORDER BY id DESC LIMIT 1");
+        stmt->set_int64(1, uid);
+        stmt->set_int64(2, peer);
+        stmt->set_int64(3, peer);
+        stmt->set_int64(4, uid);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return false;
+        }
+        out.id = static_cast<UserId>(rs->get_int64("id"));
+        out.from_uid = static_cast<UserId>(rs->get_int64("from_uid"));
+        out.to_uid = static_cast<UserId>(rs->get_int64("to_uid"));
+        out.msg_type = rs->get_int32("msg_type");
+        out.content = rs->get_string("content");
+        out.ts = rs->get_int64("ts") * 1000;
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("latest_visible: ") + e.what());
+        throw;
+    }
+}
+
+void MessageStore::set_conversation_last(UserId uid, UserId peer, UserId msg_id,
+                                         const std::string& preview)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_conversation SET last_msg_id=?, last_content=? WHERE uid=? AND peer_uid=?");
+        stmt->set_int64(1, msg_id);
+        stmt->set_string(2, preview);
+        stmt->set_int64(3, uid);
+        stmt->set_int64(4, peer);
+        stmt->update();
+    } catch (const std::exception& e) {
+        Logger::error(std::string("set_conversation_last: ") + e.what());
+        throw;
+    }
+}
+
 std::vector<ConvRow> MessageStore::list_conversations(UserId uid)
 {
     std::vector<ConvRow> out;
@@ -151,7 +264,8 @@ std::vector<MsgRow> MessageStore::list_history(UserId uid, UserId peer, UserId l
         auto conn = mysql_.acquire();
         auto* stmt = conn->setsql(
             "SELECT id, from_uid, to_uid, msg_type, content, UNIX_TIMESTAMP(created_at) AS ts "
-            "FROM im_message WHERE ((from_uid=? AND to_uid=?) OR (from_uid=? AND to_uid=?)) "
+            "FROM im_message WHERE ((from_uid=? AND to_uid=? AND sender_deleted=0) "
+            "OR (from_uid=? AND to_uid=? AND receiver_deleted=0)) "
             "AND (?=0 OR id<?) ORDER BY id DESC LIMIT ?");
         stmt->set_int64(1, uid);
         stmt->set_int64(2, peer);

+ 5 - 1
server/src/db/message_store.h

@@ -43,8 +43,12 @@ public:
                              bool inc_unread);
     void clear_unread(UserId uid, UserId peer);
     void set_unread(UserId uid, UserId peer, int unread);
-    // 删除当前用户的会话摘要,并永久删除双方之间的聊天记录
+    // 删除当前用户的会话摘要,并标记该用户侧的消息为已删除(对方仍可见)
     void delete_conversation(UserId uid, UserId peer);
+    // 仅标记当前用户侧已删除;返回实际改动条数。随后刷新该会话预览。
+    int delete_messages(UserId uid, UserId peer, const std::vector<UserId>& ids);
+    bool latest_visible(UserId uid, UserId peer, MsgRow& out);
+    void set_conversation_last(UserId uid, UserId peer, UserId msg_id, const std::string& preview);
     std::vector<ConvRow> list_conversations(UserId uid);
     // last_id=0 拉最新一页;>0 拉比该 id 更早的消息。返回按时间正序。
     std::vector<MsgRow> list_history(UserId uid, UserId peer, UserId last_id, int limit);

+ 2 - 0
server/src/db/mysql_service.cpp

@@ -197,6 +197,8 @@ bool MysqlService::ensure_schema()
         "ALTER TABLE im_friend_request ADD COLUMN message VARCHAR(64) NOT NULL DEFAULT ''",
         "ALTER TABLE im_friend ADD COLUMN remark VARCHAR(64) NOT NULL DEFAULT ''",
         "ALTER TABLE im_user ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0",
+        "ALTER TABLE im_message ADD COLUMN sender_deleted TINYINT NOT NULL DEFAULT 0",
+        "ALTER TABLE im_message ADD COLUMN receiver_deleted TINYINT NOT NULL DEFAULT 0",
         "CREATE TABLE IF NOT EXISTS im_friend_request ("
         "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,"
         "from_uid BIGINT UNSIGNED NOT NULL,"

+ 3 - 0
server/src/im/handler.cpp

@@ -128,6 +128,9 @@ ylib::json IHandler::limits_json()
     o["history_max"] = static_cast<int64>(lim.history_max);
     o["avatar_max"] = static_cast<int64>(lim.avatar_max);
     o["file_max"] = static_cast<int64>(lim.file_max);
+    o["max_packet"] = static_cast<int64>(config().protocol().max_packet);
+    o["heartbeat_sec"] = static_cast<int64>(config().client().heartbeat_sec);
+    o["interval_sec"] = static_cast<int64>(config().sms().interval_sec);
     uint32_t code_len = config().sms().code_len;
     if (code_len == 0) {
         code_len = 6;

+ 1 - 0
server/src/im/handler.h

@@ -45,6 +45,7 @@ protected:
     std::string json_str(const char* key);
     int64 json_i64(const char* key, int64 fallback = 0);
     bool json_has(const char* key);
+    const ylib::json& json_body() const { return req_; }
 
     void reply_ok();
     void reply_ok(ylib::json body);

+ 1 - 0
server/src/im/handlers/account_handler.cpp

@@ -273,6 +273,7 @@ void AccountHandler::on_send_code()
 
     ylib::json body;
     body["ttl"] = static_cast<int64>(sms_cfg.ttl_sec);
+    body["interval"] = static_cast<int64>(sms_cfg.interval_sec);
     if (sms_cfg.echo_code) {
         body["sms_code"] = code;
     }

+ 496 - 0
server/src/im/handlers/favorite_handler.cpp

@@ -0,0 +1,496 @@
+#include "im/handlers/favorite_handler.h"
+
+#include <algorithm>
+#include <string>
+#include <vector>
+
+#include "common/i18n.h"
+#include "common/logger.h"
+#include "common/protocol_def.h"
+#include "db/file_store.h"
+#include "db/favorite_store.h"
+#include "third/cos.h"
+
+namespace im {
+namespace {
+
+constexpr size_t kNoteMaxBytes = 65536;
+constexpr size_t kTitleMax = 120;
+
+bool looks_like_image(const std::string& mime, const std::string& name)
+{
+    auto lower = [](std::string s) {
+        for (char& c : s) {
+            if (c >= 'A' && c <= 'Z') {
+                c = static_cast<char>(c - 'A' + 'a');
+            }
+        }
+        return s;
+    };
+    const std::string m = lower(mime);
+    if (m.rfind("image/", 0) == 0) {
+        return true;
+    }
+    const std::string n = lower(name);
+    const auto dot = n.find_last_of('.');
+    if (dot == std::string::npos) {
+        return false;
+    }
+    const std::string ext = n.substr(dot);
+    return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".bmp" ||
+           ext == ".webp";
+}
+
+std::string html_text(const std::string& html)
+{
+    auto lower_ch = [](unsigned char c) -> unsigned char {
+        return (c >= 'A' && c <= 'Z') ? static_cast<unsigned char>(c - 'A' + 'a') : c;
+    };
+    auto ieq = [&](char a, char b) {
+        return lower_ch(static_cast<unsigned char>(a)) == lower_ch(static_cast<unsigned char>(b));
+    };
+    auto is_named_tag = [&](size_t i, const char* tag) {
+        if (i >= html.size() || html[i] != '<') {
+            return false;
+        }
+        size_t j = i + 1;
+        if (j < html.size() && html[j] == '/') {
+            ++j;
+        }
+        for (size_t k = 0; tag[k] != '\0'; ++k, ++j) {
+            if (j >= html.size() || !ieq(html[j], tag[k])) {
+                return false;
+            }
+        }
+        return j >= html.size() || html[j] == '>' || html[j] == ' ' || html[j] == '\t' ||
+               html[j] == '\n' || html[j] == '/';
+    };
+    auto skip_to_gt = [&](size_t& i) {
+        while (i < html.size() && html[i] != '>') {
+            ++i;
+        }
+        if (i < html.size()) {
+            ++i;
+        }
+    };
+
+    std::string out;
+    for (size_t i = 0; i < html.size();) {
+        if (html[i] == '<') {
+            const char* skip_tag = nullptr;
+            if (is_named_tag(i, "style")) {
+                skip_tag = "style";
+            } else if (is_named_tag(i, "script")) {
+                skip_tag = "script";
+            } else if (is_named_tag(i, "head")) {
+                skip_tag = "head";
+            }
+            if (skip_tag != nullptr) {
+                skip_to_gt(i);
+                while (i < html.size()) {
+                    if (html[i] == '<' && i + 1 < html.size() && html[i + 1] == '/' &&
+                        is_named_tag(i, skip_tag)) {
+                        skip_to_gt(i);
+                        break;
+                    }
+                    ++i;
+                }
+                continue;
+            }
+            skip_to_gt(i);
+            continue;
+        }
+        if (html[i] == '&') {
+            const size_t semi = html.find(';', i);
+            if (semi != std::string::npos && semi - i < 12) {
+                const std::string ent = html.substr(i, semi - i + 1);
+                if (ent == "&nbsp;" || ent == "&#160;" || ent == "&#xa0;") {
+                    out.push_back(' ');
+                } else if (ent == "&lt;") {
+                    out += '<';
+                } else if (ent == "&gt;") {
+                    out += '>';
+                } else if (ent == "&amp;") {
+                    out += '&';
+                } else if (ent == "&quot;") {
+                    out += '"';
+                } else if (ent == "&apos;" || ent == "&#39;") {
+                    out += '\'';
+                } else {
+                    out.push_back(' ');
+                }
+                i = semi + 1;
+                continue;
+            }
+        }
+        const unsigned char c = static_cast<unsigned char>(html[i]);
+        out.push_back((c == '\n' || c == '\r' || c == '\t') ? ' ' : static_cast<char>(c));
+        ++i;
+    }
+
+    std::string collapsed;
+    collapsed.reserve(out.size());
+    bool space = true;
+    for (char c : out) {
+        if (c == ' ') {
+            if (!space) {
+                collapsed.push_back(' ');
+            }
+            space = true;
+        } else {
+            collapsed.push_back(c);
+            space = false;
+        }
+    }
+    if (!collapsed.empty() && collapsed.back() == ' ') {
+        collapsed.pop_back();
+    }
+    return collapsed;
+}
+
+std::string clip_title(std::string s)
+{
+    if (s.size() > kTitleMax) {
+        s.resize(kTitleMax);
+    }
+    return s;
+}
+
+int64 field_num(const ylib::json& j, const char* key)
+{
+    if (key == nullptr || !j.exist(key)) {
+        return 0;
+    }
+    if (j[key].is_number()) {
+        return j[key].to<int64>();
+    }
+    const std::string s = j[key].to<std::string>();
+    if (s.empty()) {
+        return 0;
+    }
+    try {
+        return static_cast<int64>(std::stoll(s));
+    } catch (...) {
+        return 0;
+    }
+}
+
+std::string field_str(const ylib::json& j, const char* key)
+{
+    if (key == nullptr || !j.exist(key)) {
+        return {};
+    }
+    return j[key].to<std::string>();
+}
+
+std::vector<UserId> collect_file_ids(const std::string& s)
+{
+    std::vector<UserId> out;
+    auto take = [&](size_t i) {
+        UserId v = 0;
+        while (i < s.size() && s[i] >= '0' && s[i] <= '9') {
+            v = v * 10 + static_cast<UserId>(s[i] - '0');
+            ++i;
+        }
+        if (v > 0) {
+            out.push_back(v);
+        }
+    };
+    for (size_t i = 0; i < s.size(); ++i) {
+        if (i + 8 <= s.size() && s.compare(i, 8, "file_id\"") == 0) {
+            size_t j = i + 8;
+            while (j < s.size() && (s[j] == '"' || s[j] == ':' || s[j] == ' ')) {
+                ++j;
+            }
+            take(j);
+        } else if (i + 8 <= s.size() && s.compare(i, 8, "favfile:") == 0) {
+            take(i + 8);
+        }
+    }
+    std::sort(out.begin(), out.end());
+    out.erase(std::unique(out.begin(), out.end()), out.end());
+    return out;
+}
+
+}  // namespace
+
+void FavoriteHandler::handle()
+{
+    switch (cmd()) {
+    case Cmd::FavoriteAdd:
+        on_add();
+        break;
+    case Cmd::FavoriteList:
+        on_list();
+        break;
+    case Cmd::FavoriteGet:
+        on_get();
+        break;
+    case Cmd::FavoriteUpdate:
+        on_update();
+        break;
+    case Cmd::FavoriteDelete:
+        on_delete();
+        break;
+    default:
+        Logger::warn("unhandled favorite cmd=" + std::to_string(ctx_.packet.cmd));
+        break;
+    }
+}
+
+void FavoriteHandler::on_add()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId uid = session()->uid();
+    int kind = static_cast<int>(json_i64("kind"));
+    int msg_type = static_cast<int>(json_i64("msg_type"));
+    std::string content = json_str("content");
+    const UserId src_msg_id = static_cast<UserId>(json_i64("src_msg_id"));
+    const UserId src_peer_uid = static_cast<UserId>(json_i64("src_peer_uid"));
+
+    if (kind <= 0) {
+        if (msg_type == MsgType::File) {
+            kind = FavKind::File;
+        } else if (msg_type == MsgType::Card) {
+            kind = FavKind::Card;
+        } else if (msg_type == MsgType::Text || msg_type == 0) {
+            kind = FavKind::Text;
+        } else {
+            reply_error(Err::BadParam, i18n::Msg::BadParam);
+            return;
+        }
+    }
+
+    if (kind != FavKind::Text && kind != FavKind::Image && kind != FavKind::File &&
+        kind != FavKind::Card && kind != FavKind::Note) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    if (content.empty()) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    if (content.size() > kNoteMaxBytes) {
+        reply_error(Err::BadParam, i18n::Msg::FavoriteContentTooLong);
+        return;
+    }
+
+    std::string title;
+    if (kind == FavKind::Text) {
+        title = clip_title(content);
+    } else if (kind == FavKind::Card) {
+        ylib::json meta;
+        if (!meta.parse(content)) {
+            reply_error(Err::BadParam, i18n::Msg::InvalidJson);
+            return;
+        }
+        title = field_str(meta, "nickname");
+        if (title.empty()) {
+            title = field_str(meta, "account");
+        }
+        if (title.empty()) {
+            title = "名片";
+        }
+        title = clip_title(title);
+    } else if (kind == FavKind::Note) {
+        ylib::json meta;
+        if (meta.parse(content) && meta.exist("html")) {
+            std::string html = field_str(meta, "html");
+            if (html.size() > kNoteMaxBytes) {
+                reply_error(Err::BadParam, i18n::Msg::FavoriteContentTooLong);
+                return;
+            }
+            title = clip_title(html_text(html));
+        } else {
+            title = "笔记";
+        }
+        if (title.empty()) {
+            title = "笔记";
+        }
+    } else {
+        ylib::json meta;
+        if (!meta.parse(content)) {
+            reply_error(Err::BadParam, i18n::Msg::InvalidJson);
+            return;
+        }
+        const UserId src_file = static_cast<UserId>(field_num(meta, "file_id"));
+        if (src_file <= 0) {
+            reply_error(Err::BadParam, i18n::Msg::FileNotFound);
+            return;
+        }
+        FileStore files(*mysql());
+        FileRow src;
+        if (!files.find(src_file, src) || src.file_path.empty()) {
+            reply_error(Err::NotFound, i18n::Msg::FileNotFound);
+            return;
+        }
+        const std::string ext = third::cos::object_ext(src.name, src.mime);
+        const UserId copy_id =
+            files.insert(uid, src.name, src.mime, src.size, src.sha256, "");
+        if (copy_id <= 0) {
+            reply_error(Err::DbError, i18n::Msg::DbError);
+            return;
+        }
+        const std::string dest = third::cos::favorite_key(config().cos(), uid, copy_id, ext);
+        const auto copied = third::cos::copy_object(config().cos(), src.file_path, dest);
+        if (!copied.ok) {
+            files.remove(copy_id);
+            reply_error(Err::DbError, i18n::Msg::DbError);
+            return;
+        }
+        files.set_path(copy_id, dest);
+        ylib::json stored;
+        stored["file_id"] = static_cast<int64>(copy_id);
+        stored["name"] = src.name;
+        stored["size"] = src.size;
+        stored["mime"] = src.mime;
+        stored["url"] = dest;
+        content = stored.to_string();
+        kind = looks_like_image(src.mime, src.name) ? FavKind::Image : FavKind::File;
+        title = clip_title(src.name.empty() ? (kind == FavKind::Image ? "图片" : "文件") : src.name);
+    }
+
+    FavoriteStore store(*mysql());
+    const UserId id = store.insert(uid, kind, title, content, src_msg_id, src_peer_uid);
+    ylib::json body;
+    body["id"] = static_cast<int64>(id);
+    body["kind"] = kind;
+    body["title"] = title;
+    reply_ok(body);
+}
+
+void FavoriteHandler::on_list()
+{
+    if (!require_login()) {
+        return;
+    }
+    int limit = static_cast<int>(json_i64("limit", 200));
+    FavoriteStore store(*mysql());
+    const auto rows = store.list(session()->uid(), limit);
+    ylib::json list;
+    list = ylib::json::type::array;
+    for (const auto& row : rows) {
+        ylib::json item;
+        item["id"] = static_cast<int64>(row.id);
+        item["kind"] = row.kind;
+        item["title"] = row.title;
+        item["ts"] = static_cast<int64>(row.ts);
+        list.push_back(item);
+    }
+    ylib::json body;
+    body["list"] = list;
+    reply_ok(body);
+}
+
+void FavoriteHandler::on_get()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId id = static_cast<UserId>(json_i64("id"));
+    if (id <= 0) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    FavoriteStore store(*mysql());
+    FavoriteRow row;
+    if (!store.find(session()->uid(), id, row)) {
+        reply_error(Err::NotFound, i18n::Msg::FavoriteNotFound);
+        return;
+    }
+    ylib::json body;
+    body["id"] = static_cast<int64>(row.id);
+    body["kind"] = row.kind;
+    body["title"] = row.title;
+    body["content"] = row.content;
+    body["src_msg_id"] = static_cast<int64>(row.src_msg_id);
+    body["src_peer_uid"] = static_cast<int64>(row.src_peer_uid);
+    body["ts"] = static_cast<int64>(row.ts);
+    body["updated_ts"] = static_cast<int64>(row.updated_ts);
+    reply_ok(body);
+}
+
+void FavoriteHandler::on_update()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId id = static_cast<UserId>(json_i64("id"));
+    std::string content = json_str("content");
+    if (id <= 0 || content.empty()) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    if (content.size() > kNoteMaxBytes) {
+        reply_error(Err::BadParam, i18n::Msg::FavoriteContentTooLong);
+        return;
+    }
+    FavoriteStore store(*mysql());
+    FavoriteRow row;
+    if (!store.find(session()->uid(), id, row)) {
+        reply_error(Err::NotFound, i18n::Msg::FavoriteNotFound);
+        return;
+    }
+    if (row.kind != FavKind::Note && row.kind != FavKind::Text) {
+        reply_error(Err::Forbidden, i18n::Msg::AccountNotAllowed);
+        return;
+    }
+    std::string title = row.title;
+    if (row.kind == FavKind::Note) {
+        ylib::json meta;
+        if (meta.parse(content) && meta.exist("html")) {
+            title = clip_title(html_text(field_str(meta, "html")));
+            if (title.empty()) {
+                title = "笔记";
+            }
+        }
+    } else {
+        title = clip_title(content);
+    }
+    store.update(session()->uid(), id, title, content);
+    ylib::json body;
+    body["id"] = static_cast<int64>(id);
+    body["title"] = title;
+    reply_ok(body);
+}
+
+void FavoriteHandler::on_delete()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId id = static_cast<UserId>(json_i64("id"));
+    if (id <= 0) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    FavoriteStore store(*mysql());
+    FavoriteRow row;
+    if (!store.find(session()->uid(), id, row)) {
+        reply_error(Err::NotFound, i18n::Msg::FavoriteNotFound);
+        return;
+    }
+    FileStore files(*mysql());
+    for (UserId fid : collect_file_ids(row.content)) {
+        FileRow fr;
+        if (!files.find(fid, fr)) {
+            continue;
+        }
+        if (fr.from_uid != session()->uid()) {
+            continue;
+        }
+        if (!third::cos::looks_like_favorite_key(config().cos(), fr.file_path)) {
+            continue;
+        }
+        third::cos::delete_object(config().cos(), fr.file_path);
+        files.remove(fid);
+    }
+    store.remove(session()->uid(), id);
+    ylib::json body;
+    body["id"] = static_cast<int64>(id);
+    reply_ok(body);
+}
+
+}  // namespace im

+ 19 - 0
server/src/im/handlers/favorite_handler.h

@@ -0,0 +1,19 @@
+#pragma once
+
+#include "im/handler.h"
+
+namespace im {
+
+class FavoriteHandler : public IHandler {
+protected:
+    void handle() override;
+
+private:
+    void on_add();
+    void on_list();
+    void on_get();
+    void on_update();
+    void on_delete();
+};
+
+}  // namespace im

+ 0 - 1
server/src/im/handlers/friend_handler.cpp

@@ -340,7 +340,6 @@ void FriendHandler::on_delete_friend()
     store.remove_friend(session()->uid(), peer);
     MessageStore msgs(*mysql());
     msgs.delete_conversation(session()->uid(), peer);
-    msgs.delete_conversation(peer, session()->uid());
     ylib::json body;
     body["uid"] = static_cast<int64>(peer);
     reply_ok(body);

+ 90 - 1
server/src/im/handlers/message_handler.cpp

@@ -1,6 +1,8 @@
 #include "im/handlers/message_handler.h"
 
+#include <algorithm>
 #include <string>
+#include <vector>
 
 #include "common/crypto.h"
 #include "common/i18n.h"
@@ -147,6 +149,9 @@ void MessageHandler::handle()
     case Cmd::GetFile:
         on_get_file();
         break;
+    case Cmd::DeleteMsg:
+        on_delete_msg();
+        break;
     default:
         Logger::warn("unhandled message cmd=" + std::to_string(ctx_.packet.cmd));
         break;
@@ -345,6 +350,7 @@ void MessageHandler::on_conv_list()
         item["preview"] = row.last_content;
         item["ts"] = static_cast<int64>(row.last_ts);
         item["unread"] = row.unread;
+        item["online"] = server().sessions().find_by_uid(row.peer_uid) ? 1 : 0;
         list.push_back(item);
     }
     ylib::json body;
@@ -369,6 +375,86 @@ void MessageHandler::on_delete_conv()
     reply_ok(body);
 }
 
+void MessageHandler::on_delete_msg()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId peer_uid = static_cast<UserId>(json_i64("peer_uid"));
+    if (peer_uid <= 0) {
+        reply_error(Err::BadParam, i18n::Msg::PeerUidRequired);
+        return;
+    }
+    std::vector<UserId> ids;
+    auto take_one = [&](const ylib::json& it) {
+        int64 v = 0;
+        if (it.is_number()) {
+            v = static_cast<int64>(it.to<double>());
+        } else {
+            const std::string s = it.is_string() ? it.to<std::string>() : it.to_string();
+            try {
+                v = static_cast<int64>(std::stoll(s));
+            } catch (...) {
+                v = 0;
+            }
+        }
+        if (v > 0) {
+            ids.push_back(static_cast<UserId>(v));
+        }
+    };
+    const UserId one = static_cast<UserId>(json_i64("id"));
+    if (one > 0) {
+        ids.push_back(one);
+    }
+    if (json_has("ids")) {
+        const ylib::json& idsJson = json_body()["ids"];
+        if (idsJson.is_array()) {
+            for (size_t i = 0; i < idsJson.size(); ++i) {
+                take_one(idsJson[i]);
+            }
+        } else {
+            take_one(idsJson);
+        }
+    }
+    std::sort(ids.begin(), ids.end());
+    ids.erase(std::unique(ids.begin(), ids.end()), ids.end());
+    if (ids.size() > 100) {
+        ids.resize(100);
+    }
+    if (ids.empty()) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+
+    const UserId uid = session()->uid();
+    MessageStore store(*mysql());
+    store.delete_messages(uid, peer_uid, ids);
+
+    MsgRow last;
+    ylib::json body;
+    body["peer_uid"] = static_cast<int64>(peer_uid);
+    if (store.latest_visible(uid, peer_uid, last)) {
+        const std::string preview =
+            chat_preview(last.msg_type, last.content, config().limits().preview_max);
+        store.set_conversation_last(uid, peer_uid, last.id, preview);
+        body["last_msg_id"] = static_cast<int64>(last.id);
+        body["preview"] = preview;
+        body["ts"] = static_cast<int64>(last.ts);
+    } else {
+        store.set_conversation_last(uid, peer_uid, 0, "");
+        body["last_msg_id"] = 0;
+        body["preview"] = "";
+        body["ts"] = static_cast<int64>(0);
+    }
+    std::vector<int64> idnums;
+    idnums.reserve(ids.size());
+    for (UserId id : ids) {
+        idnums.push_back(static_cast<int64>(id));
+    }
+    body["ids"] = idnums;
+    reply_ok(body);
+}
+
 void MessageHandler::on_set_unread()
 {
     if (!require_login()) {
@@ -433,8 +519,11 @@ void MessageHandler::on_put_file()
         return;
     }
 
+    const std::string purpose = json_str("purpose");
     const std::string ext = third::cos::object_ext(name, mime);
-    const std::string stored = third::cos::file_key(config().cos(), file_id, ext);
+    const std::string stored = (purpose == "favorite")
+        ? third::cos::favorite_key(config().cos(), session()->uid(), file_id, ext)
+        : third::cos::file_key(config().cos(), file_id, ext);
     files.set_path(file_id, stored);
 
     const auto tok = third::cos::issue_put_token(config().cos(), stored, mime,

+ 1 - 0
server/src/im/handlers/message_handler.h

@@ -16,6 +16,7 @@ private:
     void on_set_unread();
     void on_put_file();
     void on_get_file();
+    void on_delete_msg();
 };
 
 }  // namespace im

+ 3 - 3
server/src/im/handlers/system_handler.cpp

@@ -12,8 +12,8 @@ void SystemHandler::handle()
     case Cmd::Heartbeat:
         on_heartbeat();
         break;
-    case Cmd::SetLang:
-        on_set_lang();
+    case Cmd::SetConfig:
+        on_set_config();
         break;
     default:
         Logger::warn("unhandled system cmd=" + std::to_string(ctx_.packet.cmd));
@@ -28,7 +28,7 @@ void SystemHandler::on_heartbeat()
     reply_ok(body);
 }
 
-void SystemHandler::on_set_lang()
+void SystemHandler::on_set_config()
 {
     const std::string lang = json_str("lang");
     if (lang.empty()) {

+ 2 - 2
server/src/im/handlers/system_handler.h

@@ -4,14 +4,14 @@
 
 namespace im {
 
-// 系统业务:心跳、语言。被踢为服务端推送,不走本 Handler。
+// 系统业务:心跳、连接配置。被踢为服务端推送,不走本 Handler。
 class SystemHandler : public IHandler {
 protected:
     void handle() override;
 
 private:
     void on_heartbeat();
-    void on_set_lang();
+    void on_set_config();
 };
 
 }  // namespace im

+ 61 - 1
server/src/third/cos.cpp

@@ -6,6 +6,7 @@
 #include <map>
 #include <mutex>
 #include <string>
+#include <vector>
 
 #include <curl/curl.h>
 #include <openssl/evp.h>
@@ -293,7 +294,8 @@ int64_t json_int64(const ylib::json& j, const char* key)
 }
 
 Result request(const CosConfig& raw, const std::string& method, const std::string& key,
-               const std::string& body, const std::string& content_type)
+               const std::string& body, const std::string& content_type,
+               const std::map<std::string, std::string>& extra_headers = {})
 {
     Result out;
     const CosConfig cfg = resolved(raw);
@@ -321,6 +323,7 @@ Result request(const CosConfig& raw, const std::string& method, const std::strin
     const std::string verb = to_lower(method);
     const bool is_put = verb == "put";
     const bool is_head = verb == "head";
+    const bool is_delete = verb == "delete";
     const std::string mime =
         content_type.empty() ? "application/octet-stream" : content_type;
 
@@ -331,6 +334,9 @@ Result request(const CosConfig& raw, const std::string& method, const std::strin
         sign_headers["content-type"] = mime;
         sign_headers["content-length"] = std::to_string(body.size());
     }
+    for (const auto& kv : extra_headers) {
+        sign_headers[to_lower(kv.first)] = kv.second;
+    }
     const std::string auth =
         authorization(method, pathname, sign_headers, cfg.secret_id, cfg.secret_key, now,
                       now + 3600);
@@ -349,6 +355,12 @@ Result request(const CosConfig& raw, const std::string& method, const std::strin
         headers = curl_slist_append(headers, h_type.c_str());
         headers = curl_slist_append(headers, h_len.c_str());
     }
+    std::vector<std::string> extra_lines;
+    extra_lines.reserve(extra_headers.size());
+    for (const auto& kv : extra_headers) {
+        extra_lines.push_back(kv.first + ": " + kv.second);
+        headers = curl_slist_append(headers, extra_lines.back().c_str());
+    }
 
     std::string response;
     curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
@@ -364,6 +376,8 @@ Result request(const CosConfig& raw, const std::string& method, const std::strin
     } else if (is_head) {
         curl_easy_setopt(curl, CURLOPT_NOBODY, 1L);
         curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "HEAD");
+    } else if (is_delete) {
+        curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
     } else {
         curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
     }
@@ -683,6 +697,23 @@ std::string sticker_key(const CosConfig& cfg, const std::string& rel_path)
     return join_key(resolved(cfg).prefix, "stickers/" + rel);
 }
 
+std::string favorite_key(const CosConfig& cfg, UserId uid, UserId file_id, const std::string& ext)
+{
+    std::string e = ext;
+    if (!e.empty() && e.front() != '.') {
+        e.insert(e.begin(), '.');
+    }
+    return join_key(resolved(cfg).prefix,
+                    "favorites/" + std::to_string(uid) + "/" + std::to_string(file_id) + e);
+}
+
+bool looks_like_favorite_key(const CosConfig& cfg, const std::string& path)
+{
+    const std::string prefix = join_key(resolved(cfg).prefix, "favorites/");
+    const std::string key = trim_slash(path);
+    return key.size() >= prefix.size() && key.compare(0, prefix.size(), prefix) == 0;
+}
+
 bool looks_like_key(const std::string& path)
 {
     return path.find('/') != std::string::npos;
@@ -716,6 +747,35 @@ Result head_object(const CosConfig& cfg, const std::string& key)
     return r;
 }
 
+Result copy_object(const CosConfig& cfg_raw, const std::string& src_key, const std::string& dst_key)
+{
+    const CosConfig cfg = resolved(cfg_raw);
+    const std::string src = trim_slash(src_key);
+    const std::string dst = trim_slash(dst_key);
+    Result out;
+    if (src.empty() || dst.empty()) {
+        out.msg = "empty copy key";
+        return out;
+    }
+    std::map<std::string, std::string> extra;
+    extra["x-cos-copy-source"] = object_host(cfg) + "/" + uri_encode_path(src);
+    extra["x-cos-metadata-directive"] = "Copy";
+    Result r = request(cfg, "PUT", dst, {}, "application/octet-stream", extra);
+    if (!r.ok) {
+        Logger::error("cos copy " + src + " -> " + dst + ": " + r.msg);
+    }
+    return r;
+}
+
+Result delete_object(const CosConfig& cfg, const std::string& key)
+{
+    Result r = request(cfg, "DELETE", key, {}, {});
+    if (!r.ok) {
+        Logger::warn("cos delete " + key + ": " + r.msg);
+    }
+    return r;
+}
+
 PutToken issue_put_token(const CosConfig& cfg_raw, const std::string& key,
                          const std::string& content_type, size_t content_length, int ttl_sec)
 {

+ 5 - 0
server/src/third/cos.h

@@ -42,12 +42,17 @@ std::string object_ext(const std::string& name, const std::string& mime);
 std::string avatar_key(const CosConfig& cfg, UserId uid);
 std::string file_key(const CosConfig& cfg, UserId file_id, const std::string& ext = {});
 std::string sticker_key(const CosConfig& cfg, const std::string& rel_path);
+std::string favorite_key(const CosConfig& cfg, UserId uid, UserId file_id,
+                         const std::string& ext = {});
 bool looks_like_key(const std::string& path);
+bool looks_like_favorite_key(const CosConfig& cfg, const std::string& path);
 
 Result put_object(const CosConfig& cfg, const std::string& key, const std::string& data,
                   const std::string& content_type);
 Result get_object(const CosConfig& cfg, const std::string& key);
 Result head_object(const CosConfig& cfg, const std::string& key);
+Result copy_object(const CosConfig& cfg, const std::string& src_key, const std::string& dst_key);
+Result delete_object(const CosConfig& cfg, const std::string& key);
 
 // 申请客户端直传 COS 的临时凭证:STS 临时密钥 + 绑定该对象的 PUT 签名。
 // 失败时回退为永久密钥签发的限时 PUT Authorization(无 session_token)。

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