Ver Fonte

Initialize NIM client and server source repository.

Co-authored-by: Cursor <cursoragent@cursor.com>
a158 há 2 semanas atrás
commit
ef4580daa7
100 ficheiros alterados com 15551 adições e 0 exclusões
  1. 48 0
      .gitignore
  2. 25 0
      Client/Client.sln
  3. 206 0
      Client/Client.vcxproj
  4. 238 0
      Client/Client.vcxproj.filters
  5. 25 0
      Client/IM.sln
  6. 27 0
      Client/config/config.ini
  7. 1 0
      Client/res/app.rc
  8. BIN
      Client/res/fonts/Alimama/AlimamaFangYuanTiVF-Thin.ttf
  9. BIN
      Client/res/fonts/Alimama/AlimamaFangYuanTiVF-Thin.woff
  10. BIN
      Client/res/fonts/Alimama/AlimamaFangYuanTiVF-Thin.woff2
  11. 6 0
      Client/res/fonts/Alimama/INSTRUCTION.txt
  12. 6 0
      Client/res/fonts/Alimama/LICENSE.txt
  13. 1 0
      Client/res/icons/.gitkeep
  14. BIN
      Client/res/icons/app.ico
  15. BIN
      Client/res/icons/settings.png
  16. 1 0
      Client/res/images/.gitkeep
  17. 190 0
      Client/res/qss/app.qss
  18. 8 0
      Client/res/res.qrc
  19. BIN
      Client/res/sounds/notify.wav
  20. 52 0
      Client/src/app/main.cpp
  21. 99 0
      Client/src/auth/ChangePasswordDialog.cpp
  22. 23 0
      Client/src/auth/ChangePasswordDialog.h
  23. 170 0
      Client/src/auth/ForgotPasswordDialog.cpp
  24. 36 0
      Client/src/auth/ForgotPasswordDialog.h
  25. 319 0
      Client/src/auth/LoginWindow.cpp
  26. 67 0
      Client/src/auth/LoginWindow.h
  27. 206 0
      Client/src/auth/RegisterDialog.cpp
  28. 41 0
      Client/src/auth/RegisterDialog.h
  29. 1 0
      Client/src/chat/.gitkeep
  30. 241 0
      Client/src/chat/AvatarCache.cpp
  31. 40 0
      Client/src/chat/AvatarCache.h
  32. 734 0
      Client/src/chat/FileCache.cpp
  33. 104 0
      Client/src/chat/FileCache.h
  34. 111 0
      Client/src/chat/FileTypeIcon.cpp
  35. 11 0
      Client/src/chat/FileTypeIcon.h
  36. 184 0
      Client/src/chat/StickerCache.cpp
  37. 53 0
      Client/src/chat/StickerCache.h
  38. 132 0
      Client/src/chat/StickerPanel.cpp
  39. 28 0
      Client/src/chat/StickerPanel.h
  40. 284 0
      Client/src/common/AppConfig.cpp
  41. 93 0
      Client/src/common/AppConfig.h
  42. 209 0
      Client/src/common/AppTheme.cpp
  43. 46 0
      Client/src/common/AppTheme.h
  44. 334 0
      Client/src/common/LocalStore.cpp
  45. 64 0
      Client/src/common/LocalStore.h
  46. 138 0
      Client/src/common/Logger.cpp
  47. 32 0
      Client/src/common/Logger.h
  48. 392 0
      Client/src/common/UiHelpers.cpp
  49. 43 0
      Client/src/common/UiHelpers.h
  50. 555 0
      Client/src/common/Updater.cpp
  51. 13 0
      Client/src/common/Updater.h
  52. 170 0
      Client/src/common/UserSettings.cpp
  53. 48 0
      Client/src/common/UserSettings.h
  54. 1 0
      Client/src/contact/.gitkeep
  55. 215 0
      Client/src/mainframe/AddFriendDialog.cpp
  56. 35 0
      Client/src/mainframe/AddFriendDialog.h
  57. 3446 0
      Client/src/mainframe/Client.cpp
  58. 205 0
      Client/src/mainframe/Client.h
  59. 213 0
      Client/src/mainframe/FriendRequestDialog.cpp
  60. 28 0
      Client/src/mainframe/FriendRequestDialog.h
  61. 118 0
      Client/src/mainframe/PickFriendDialog.cpp
  62. 17 0
      Client/src/mainframe/PickFriendDialog.h
  63. BIN
      Client/src/mainframe/SettingsDialog.cpp
  64. BIN
      Client/src/mainframe/SettingsDialog.h
  65. 1 0
      Client/src/model/.gitkeep
  66. 1 0
      Client/src/network/.gitkeep
  67. 604 0
      Client/src/network/HttpFetch.cpp
  68. 29 0
      Client/src/network/HttpFetch.h
  69. 652 0
      Client/src/network/ImClient.cpp
  70. 137 0
      Client/src/network/ImClient.h
  71. 114 0
      Client/src/network/Protocol.cpp
  72. 67 0
      Client/src/network/Protocol.h
  73. 1 0
      Client/src/session/.gitkeep
  74. 278 0
      Client/src/widgets/AvatarCropDialog.cpp
  75. 15 0
      Client/src/widgets/AvatarCropDialog.h
  76. 149 0
      Client/src/widgets/ConfirmDialog.cpp
  77. 31 0
      Client/src/widgets/ConfirmDialog.h
  78. 649 0
      Client/src/widgets/FramelessHelper.cpp
  79. 75 0
      Client/src/widgets/FramelessHelper.h
  80. 97 0
      Client/src/widgets/ImagePreviewDialog.cpp
  81. 18 0
      Client/src/widgets/ImagePreviewDialog.h
  82. 150 0
      Client/src/widgets/PromptDialog.cpp
  83. 33 0
      Client/src/widgets/PromptDialog.h
  84. 308 0
      Client/src/widgets/TitleBar.cpp
  85. 67 0
      Client/src/widgets/TitleBar.h
  86. 226 0
      common/i18n.h
  87. 141 0
      common/protocol_def.h
  88. 41 0
      docs/README.md
  89. 664 0
      docs/protocol.md
  90. 4 0
      server/.gitignore
  91. 70 0
      server/CMakeLists.txt
  92. 22 0
      server/README.md
  93. 117 0
      server/config/config.ini.example
  94. 65 0
      server/init.sh
  95. 124 0
      server/sql/init.sql
  96. 111 0
      server/sql/migrate.sql
  97. 168 0
      server/src/app/application.cpp
  98. 29 0
      server/src/app/application.h
  99. 36 0
      server/src/common/byte_order.h
  100. 124 0
      server/src/common/crypto.cpp

+ 48 - 0
.gitignore

@@ -0,0 +1,48 @@
+# IDE / editor
+.vs/
+.vscode/
+.idea/
+.cache/
+.clangd
+*.user
+*.suo
+*.sdf
+*.opensdf
+*.VC.db
+*.VC.opendb
+*.ncb
+
+# Build outputs
+**/bin/
+**/obj/
+**/build/
+**/out/
+**/Debug/
+**/Release/
+*.obj
+*.o
+*.exe
+*.dll
+*.lib
+*.pdb
+*.ilk
+*.idb
+*.exp
+compile_commands.json
+
+# Qt generated
+**/moc_*.cpp
+**/qrc_*.cpp
+**/.qmake.stash
+
+# Third-party binaries (ffmpeg / gstreamer / etc.)
+Client/3rdparty/
+server/3rdparty/
+
+# Runtime data and logs
+server/data/
+*.log
+
+# Secrets and local user state
+server/config/config.ini
+**/user.ini

+ 25 - 0
Client/Client.sln

@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.37614.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Client", "Client.vcxproj", "{10721793-7CD9-44A9-9620-07A2090C3B1C}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|x64 = Debug|x64
+		Release|x64 = Release|x64
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Debug|x64.ActiveCfg = Debug|x64
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Debug|x64.Build.0 = Debug|x64
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Release|x64.ActiveCfg = Release|x64
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Release|x64.Build.0 = Release|x64
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {FBFDE1D1-4573-4136-9210-17A3CCD5FB5C}
+	EndGlobalSection
+EndGlobal

+ 206 - 0
Client/Client.vcxproj

@@ -0,0 +1,206 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project DefaultTargets="Build" ToolsVersion="17.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <ItemGroup Label="ProjectConfigurations">
+    <ProjectConfiguration Include="Debug|x64">
+      <Configuration>Debug</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+    <ProjectConfiguration Include="Release|x64">
+      <Configuration>Release</Configuration>
+      <Platform>x64</Platform>
+    </ProjectConfiguration>
+  </ItemGroup>
+  <PropertyGroup Label="Globals">
+    <ProjectGuid>{10721793-7CD9-44A9-9620-07A2090C3B1C}</ProjectGuid>
+    <Keyword>QtVS_v304</Keyword>
+    <WindowsTargetPlatformVersion Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">10.0</WindowsTargetPlatformVersion>
+    <WindowsTargetPlatformVersion Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">10.0</WindowsTargetPlatformVersion>
+    <QtMsBuild Condition="'$(QtMsBuild)'=='' OR !Exists('$(QtMsBuild)\qt.targets')">$(MSBuildProjectDirectory)\QtMsBuild</QtMsBuild>
+  </PropertyGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="Configuration">
+    <ConfigurationType>Application</ConfigurationType>
+    <PlatformToolset>v143</PlatformToolset>
+    <UseDebugLibraries>true</UseDebugLibraries>
+    <CharacterSet>Unicode</CharacterSet>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'" Label="Configuration">
+    <ConfigurationType>Application</ConfigurationType>
+    <PlatformToolset>v143</PlatformToolset>
+    <UseDebugLibraries>false</UseDebugLibraries>
+    <WholeProgramOptimization>true</WholeProgramOptimization>
+    <CharacterSet>Unicode</CharacterSet>
+  </PropertyGroup>
+  <Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
+  <ImportGroup Condition="Exists('$(QtMsBuild)\qt_defaults.props')">
+    <Import Project="$(QtMsBuild)\qt_defaults.props" />
+  </ImportGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="QtSettings">
+    <QtInstall>5.12.11_msvc2017_64</QtInstall>
+    <QtModules>core;gui;widgets;network;sql</QtModules>
+    <QtBuildConfig>debug</QtBuildConfig>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'" Label="QtSettings">
+    <QtInstall>5.12.11_msvc2017_64</QtInstall>
+    <QtModules>core;gui;widgets;network;sql</QtModules>
+    <QtBuildConfig>release</QtBuildConfig>
+  </PropertyGroup>
+  <Target Name="QtMsBuildNotFound" BeforeTargets="CustomBuild;ClCompile" Condition="!Exists('$(QtMsBuild)\qt.targets') or !Exists('$(QtMsBuild)\qt.props')">
+    <Message Importance="High" Text="QtMsBuild: could not locate qt.targets, qt.props; project may not build correctly." />
+  </Target>
+  <ImportGroup Label="ExtensionSettings" />
+  <ImportGroup Label="Shared" />
+  <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
+    <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
+    <Import Project="$(QtMsBuild)\Qt.props" />
+  </ImportGroup>
+  <ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
+    <Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
+    <Import Project="$(QtMsBuild)\Qt.props" />
+  </ImportGroup>
+  <PropertyGroup Label="UserMacros" />
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
+    <OutDir>$(SolutionDir)bin\$(Configuration)</OutDir>
+    <IntDir>$(SolutionDir)obj\$(Configuration)</IntDir>
+    <TargetName>NIM</TargetName>
+    <ApplicationIcon>res\icons\app.ico</ApplicationIcon>
+  </PropertyGroup>
+  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
+    <OutDir>$(SolutionDir)bin\$(Configuration)</OutDir>
+    <IntDir>$(SolutionDir)obj\$(Configuration)</IntDir>
+    <TargetName>NIM</TargetName>
+    <ApplicationIcon>res\icons\app.ico</ApplicationIcon>
+  </PropertyGroup>
+  <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="Configuration">
+    <ClCompile>
+      <MultiProcessorCompilation>true</MultiProcessorCompilation>
+      <WarningLevel>Level3</WarningLevel>
+      <SDLCheck>true</SDLCheck>
+      <ConformanceMode>true</ConformanceMode>
+      <AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
+      <AdditionalIncludeDirectories>$(ProjectDir)..;$(ProjectDir)src;$(ProjectDir)src\app;$(ProjectDir)src\common;$(ProjectDir)src\widgets;$(ProjectDir)src\auth;$(ProjectDir)src\mainframe;$(ProjectDir)src\chat;$(ProjectDir)src\contact;$(ProjectDir)src\model;$(ProjectDir)src\network;$(ProjectDir)src\session;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
+    </ClCompile>
+    <Link>
+      <SubSystem>Windows</SubSystem>
+      <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"
+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"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpeg.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpeg.dll" "$(OutDir)imageformats\qjpeg.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpegd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpegd.dll" "$(OutDir)imageformats\qjpegd.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" "$(OutDir)imageformats\qgif.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" "$(OutDir)imageformats\qgifd.dll"
+if not exist "$(OutDir)3rdparty\7z\x64" mkdir "$(OutDir)3rdparty\7z\x64"
+if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7z\x64\7za.exe" "$(OutDir)3rdparty\7z\x64\7za.exe"</Command>
+    </PostBuildEvent>
+  </ItemDefinitionGroup>
+  <ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'" Label="Configuration">
+    <ClCompile>
+      <MultiProcessorCompilation>true</MultiProcessorCompilation>
+      <WarningLevel>Level3</WarningLevel>
+      <SDLCheck>true</SDLCheck>
+      <ConformanceMode>true</ConformanceMode>
+      <AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
+      <AdditionalIncludeDirectories>$(ProjectDir)..;$(ProjectDir)src;$(ProjectDir)src\app;$(ProjectDir)src\common;$(ProjectDir)src\widgets;$(ProjectDir)src\auth;$(ProjectDir)src\mainframe;$(ProjectDir)src\chat;$(ProjectDir)src\contact;$(ProjectDir)src\model;$(ProjectDir)src\network;$(ProjectDir)src\session;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
+      <FunctionLevelLinking>true</FunctionLevelLinking>
+      <IntrinsicFunctions>true</IntrinsicFunctions>
+    </ClCompile>
+    <Link>
+      <SubSystem>Windows</SubSystem>
+      <GenerateDebugInformation>false</GenerateDebugInformation>
+      <EnableCOMDATFolding>true</EnableCOMDATFolding>
+      <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"
+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"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpeg.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpeg.dll" "$(OutDir)imageformats\qjpeg.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpegd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qjpegd.dll" "$(OutDir)imageformats\qjpegd.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgif.dll" "$(OutDir)imageformats\qgif.dll"
+if exist "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" copy /Y "C:\Qt\Qt5.12.11\5.12.11\msvc2017_64\plugins\imageformats\qgifd.dll" "$(OutDir)imageformats\qgifd.dll"
+if not exist "$(OutDir)3rdparty\7z\x64" mkdir "$(OutDir)3rdparty\7z\x64"
+if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7z\x64\7za.exe" "$(OutDir)3rdparty\7z\x64\7za.exe"</Command>
+    </PostBuildEvent>
+  </ItemDefinitionGroup>
+  <ItemGroup>
+    <QtRcc Include="res\res.qrc" />
+    <QtMoc Include="src\mainframe\Client.h" />
+    <QtMoc Include="src\auth\LoginWindow.h" />
+    <QtMoc Include="src\auth\RegisterDialog.h" />
+    <QtMoc Include="src\auth\ForgotPasswordDialog.h" />
+    <QtMoc Include="src\auth\ChangePasswordDialog.h" />
+    <QtMoc Include="src\widgets\TitleBar.h" />
+    <QtMoc Include="src\widgets\FramelessHelper.h" />
+    <QtMoc Include="src\network\ImClient.h" />
+    <QtMoc Include="src\mainframe\AddFriendDialog.h" />
+    <QtMoc Include="src\mainframe\FriendRequestDialog.h" />
+    <QtMoc Include="src\chat\StickerCache.h" />
+    <QtMoc Include="src\chat\AvatarCache.h" />
+    <QtMoc Include="src\chat\FileCache.h" />
+    <QtMoc Include="src\chat\StickerPanel.h" />
+    <ClInclude Include="src\common\AppTheme.h" />
+    <ClInclude Include="src\common\UiHelpers.h" />
+    <ClInclude Include="src\common\AppConfig.h" />
+    <ClInclude Include="src\common\UserSettings.h" />
+    <ClInclude Include="src\common\Logger.h" />
+    <ClInclude Include="src\common\LocalStore.h" />
+    <ClInclude Include="src\network\Protocol.h" />
+    <ClInclude Include="src\network\HttpFetch.h" />
+    <ClCompile Include="src\app\main.cpp" />
+    <ClCompile Include="src\common\AppTheme.cpp" />
+    <ClCompile Include="src\common\UiHelpers.cpp" />
+    <ClCompile Include="src\common\AppConfig.cpp" />
+    <ClCompile Include="src\common\UserSettings.cpp" />
+    <ClCompile Include="src\common\Logger.cpp" />
+    <ClCompile Include="src\common\LocalStore.cpp" />
+    <ClCompile Include="src\common\Updater.cpp" />
+    <ClInclude Include="src\common\Updater.h" />
+    <ClCompile Include="src\network\Protocol.cpp" />
+    <ClCompile Include="src\network\HttpFetch.cpp" />
+    <ClCompile Include="src\network\ImClient.cpp" />
+    <ClCompile Include="src\widgets\TitleBar.cpp" />
+    <ClInclude Include="src\widgets\ConfirmDialog.h" />
+    <ClInclude Include="src\widgets\PromptDialog.h" />
+    <ClInclude Include="src\widgets\AvatarCropDialog.h" />
+    <ClInclude Include="src\widgets\ImagePreviewDialog.h" />
+    <ClCompile Include="src\widgets\ConfirmDialog.cpp" />
+    <ClCompile Include="src\widgets\PromptDialog.cpp" />
+    <ClCompile Include="src\widgets\AvatarCropDialog.cpp" />
+    <ClCompile Include="src\widgets\ImagePreviewDialog.cpp" />
+    <ClCompile Include="src\widgets\FramelessHelper.cpp" />
+    <ClCompile Include="src\auth\LoginWindow.cpp" />
+    <ClCompile Include="src\auth\RegisterDialog.cpp" />
+    <ClCompile Include="src\auth\ForgotPasswordDialog.cpp" />
+    <ClCompile Include="src\auth\ChangePasswordDialog.cpp" />
+    <ClInclude Include="src\mainframe\SettingsDialog.h" />
+    <ClInclude Include="src\mainframe\PickFriendDialog.h" />
+    <ClCompile Include="src\mainframe\Client.cpp" />
+    <ClCompile Include="src\mainframe\SettingsDialog.cpp" />
+    <ClCompile Include="src\mainframe\PickFriendDialog.cpp" />
+    <ClCompile Include="src\mainframe\AddFriendDialog.cpp" />
+    <ClCompile Include="src\mainframe\FriendRequestDialog.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" />
+  <ImportGroup Condition="Exists('$(QtMsBuild)\qt.targets')">
+    <Import Project="$(QtMsBuild)\qt.targets" />
+  </ImportGroup>
+  <ImportGroup Label="ExtensionTargets">
+  </ImportGroup>
+</Project>

+ 238 - 0
Client/Client.vcxproj.filters

@@ -0,0 +1,238 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <ItemGroup>
+    <Filter Include="src">
+      <UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\app">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111111}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\common">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111112}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\widgets">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111113}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\auth">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111114}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\mainframe">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111115}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\chat">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111116}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\contact">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111117}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\model">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111118}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\network">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-111111111119}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="src\session">
+      <UniqueIdentifier>{a1111111-1111-1111-1111-11111111111a}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="res">
+      <UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
+    </Filter>
+    <Filter Include="res\qss">
+      <UniqueIdentifier>{b1111111-1111-1111-1111-111111111111}</UniqueIdentifier>
+    </Filter>
+    <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">
+      <Filter>res</Filter>
+    </QtRcc>
+    <QtMoc Include="src\mainframe\Client.h">
+      <Filter>src\mainframe</Filter>
+    </QtMoc>
+    <ClInclude Include="src\mainframe\SettingsDialog.h">
+      <Filter>src\mainframe</Filter>
+    </ClInclude>
+    <ClCompile Include="src\mainframe\Client.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <ClCompile Include="src\mainframe\SettingsDialog.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <QtMoc Include="src\mainframe\AddFriendDialog.h">
+      <Filter>src\mainframe</Filter>
+    </QtMoc>
+    <ClCompile Include="src\mainframe\AddFriendDialog.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <QtMoc Include="src\mainframe\FriendRequestDialog.h">
+      <Filter>src\mainframe</Filter>
+    </QtMoc>
+    <ClCompile Include="src\mainframe\FriendRequestDialog.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <QtMoc Include="src\chat\StickerCache.h">
+      <Filter>src\chat</Filter>
+    </QtMoc>
+    <ClCompile Include="src\chat\StickerCache.cpp">
+      <Filter>src\chat</Filter>
+    </ClCompile>
+    <QtMoc Include="src\chat\AvatarCache.h">
+      <Filter>src\chat</Filter>
+    </QtMoc>
+    <ClCompile Include="src\chat\AvatarCache.cpp">
+      <Filter>src\chat</Filter>
+    </ClCompile>
+    <QtMoc Include="src\chat\FileCache.h">
+      <Filter>src\chat</Filter>
+    </QtMoc>
+    <ClCompile Include="src\chat\FileCache.cpp">
+      <Filter>src\chat</Filter>
+    </ClCompile>
+    <ClInclude Include="src\chat\FileTypeIcon.h">
+      <Filter>src\chat</Filter>
+    </ClInclude>
+    <ClCompile Include="src\chat\FileTypeIcon.cpp">
+      <Filter>src\chat</Filter>
+    </ClCompile>
+    <ClInclude Include="src\widgets\AvatarCropDialog.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\AvatarCropDialog.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <ClInclude Include="src\widgets\ImagePreviewDialog.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\ImagePreviewDialog.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <ClInclude Include="src\mainframe\PickFriendDialog.h">
+      <Filter>src\mainframe</Filter>
+    </ClInclude>
+    <ClCompile Include="src\mainframe\PickFriendDialog.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
+    <QtMoc Include="src\chat\StickerPanel.h">
+      <Filter>src\chat</Filter>
+    </QtMoc>
+    <ClCompile Include="src\chat\StickerPanel.cpp">
+      <Filter>src\chat</Filter>
+    </ClCompile>
+    <ClCompile Include="src\app\main.cpp">
+      <Filter>src\app</Filter>
+    </ClCompile>
+    <ClInclude Include="src\common\AppTheme.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
+    <ClCompile Include="src\common\AppTheme.cpp">
+      <Filter>src\common</Filter>
+    </ClCompile>
+    <ClInclude Include="src\common\UiHelpers.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
+    <ClCompile Include="src\common\UiHelpers.cpp">
+      <Filter>src\common</Filter>
+    </ClCompile>
+    <ClInclude Include="src\common\AppConfig.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
+    <ClCompile Include="src\common\AppConfig.cpp">
+      <Filter>src\common</Filter>
+    </ClCompile>
+    <ClInclude Include="src\common\UserSettings.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
+    <ClCompile Include="src\common\UserSettings.cpp">
+      <Filter>src\common</Filter>
+    </ClCompile>
+    <ClInclude Include="src\common\Logger.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
+    <ClCompile Include="src\common\Logger.cpp">
+      <Filter>src\common</Filter>
+    </ClCompile>
+    <ClInclude Include="src\common\LocalStore.h">
+      <Filter>src\common</Filter>
+    </ClInclude>
+    <ClCompile Include="src\common\LocalStore.cpp">
+      <Filter>src\common</Filter>
+    </ClCompile>
+    <QtMoc Include="src\network\ImClient.h">
+      <Filter>src\network</Filter>
+    </QtMoc>
+    <ClCompile Include="src\network\ImClient.cpp">
+      <Filter>src\network</Filter>
+    </ClCompile>
+    <ClInclude Include="src\network\Protocol.h">
+      <Filter>src\network</Filter>
+    </ClInclude>
+    <ClInclude Include="src\network\HttpFetch.h">
+      <Filter>src\network</Filter>
+    </ClInclude>
+    <ClCompile Include="src\network\Protocol.cpp">
+      <Filter>src\network</Filter>
+    </ClCompile>
+    <ClCompile Include="src\network\HttpFetch.cpp">
+      <Filter>src\network</Filter>
+    </ClCompile>
+    <QtMoc Include="src\widgets\TitleBar.h">
+      <Filter>src\widgets</Filter>
+    </QtMoc>
+    <ClCompile Include="src\widgets\TitleBar.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <ClInclude Include="src\widgets\ConfirmDialog.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\ConfirmDialog.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <ClInclude Include="src\widgets\PromptDialog.h">
+      <Filter>src\widgets</Filter>
+    </ClInclude>
+    <ClCompile Include="src\widgets\PromptDialog.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <QtMoc Include="src\widgets\FramelessHelper.h">
+      <Filter>src\widgets</Filter>
+    </QtMoc>
+    <ClCompile Include="src\widgets\FramelessHelper.cpp">
+      <Filter>src\widgets</Filter>
+    </ClCompile>
+    <QtMoc Include="src\auth\LoginWindow.h">
+      <Filter>src\auth</Filter>
+    </QtMoc>
+    <ClCompile Include="src\auth\LoginWindow.cpp">
+      <Filter>src\auth</Filter>
+    </ClCompile>
+    <QtMoc Include="src\auth\RegisterDialog.h">
+      <Filter>src\auth</Filter>
+    </QtMoc>
+    <ClCompile Include="src\auth\RegisterDialog.cpp">
+      <Filter>src\auth</Filter>
+    </ClCompile>
+    <QtMoc Include="src\auth\ForgotPasswordDialog.h">
+      <Filter>src\auth</Filter>
+    </QtMoc>
+    <ClCompile Include="src\auth\ForgotPasswordDialog.cpp">
+      <Filter>src\auth</Filter>
+    </ClCompile>
+    <QtMoc Include="src\auth\ChangePasswordDialog.h">
+      <Filter>src\auth</Filter>
+    </QtMoc>
+    <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>
+  </ItemGroup>
+</Project>

+ 25 - 0
Client/IM.sln

@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.14.37614.0
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Client", "Client.vcxproj", "{10721793-7CD9-44A9-9620-07A2090C3B1C}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|x64 = Debug|x64
+		Release|x64 = Release|x64
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Debug|x64.ActiveCfg = Debug|x64
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Debug|x64.Build.0 = Debug|x64
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Release|x64.ActiveCfg = Release|x64
+		{10721793-7CD9-44A9-9620-07A2090C3B1C}.Release|x64.Build.0 = Release|x64
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {FBFDE1D1-4573-4136-9210-17A3CCD5FB5C}
+	EndGlobalSection
+EndGlobal

+ 27 - 0
Client/config/config.ini

@@ -0,0 +1,27 @@
+[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

+ 1 - 0
Client/res/app.rc

@@ -0,0 +1 @@
+1 ICON "icons/app.ico"

BIN
Client/res/fonts/Alimama/AlimamaFangYuanTiVF-Thin.ttf


BIN
Client/res/fonts/Alimama/AlimamaFangYuanTiVF-Thin.woff


BIN
Client/res/fonts/Alimama/AlimamaFangYuanTiVF-Thin.woff2


+ 6 - 0
Client/res/fonts/Alimama/INSTRUCTION.txt

@@ -0,0 +1,6 @@
+使用反馈、商业合作等问题,请email:alimama-font@list.alibaba-inc.com
+
+重要!安装前请先阅读此文档
+请先按照下面流程安装文件夹里的字体包。
+1.  打开字体文件夹,根据不同系统需要选择 OTF 或 TTF 版本 
+2.  字体所包含字重、字宽2个可变轴,同时包含 XX 预设值(双击安装)。 

+ 6 - 0
Client/res/fonts/Alimama/LICENSE.txt

@@ -0,0 +1,6 @@
+1.  阿里妈妈方圆体字体及包含该字体的字库软件,合称“阿里妈妈方圆体”。 
+2.  阿里妈妈方圆体的知识产权和相关权益归属于淘宝(中国)软件有限公司(以下简称“阿里妈妈”),受《中华人民共和国著作权法》及其他适用法律法规、国际公约、条约的保护。 
+3.  阿里妈妈授权个人、企业等用户在遵守本声明相关条款的前提下,可以下载、安装和使用上述阿里妈妈字体,该授权是免费的普通许可,用户可基于合法目的用于商业用途或非商业用途,以及嵌入式使用,但不得以任何违反本声明第4条及法律法规、政策、规章或公序良俗的方式使用。 
+4.  除本法律声明中明确授权之外,阿里妈妈未授予用户关于阿里妈妈方圆体的其他权利。未经阿里妈妈授权,任何人不得:1)对阿里妈妈方圆体进行仿制、转换、翻译、反编译、反向工程、拆分、破解或以其他方式试图从该字库软件获取源代码;2)对阿里妈妈方圆体进行新增、拆分、修改或以其他方式进行二次创作;3)对阿里妈妈方圆体进行有偿方式转让、授权或许可给第三方/商家/用户使用;4) 标注错误或不标注版权所有人阿里妈妈,无法标注版权所有人的情况除外;5)发布任何使外界误认其与阿里妈妈或其关联公司存在合作、赞助或背书等商业关联的不实信息。 
+5.  阿里妈妈授予用户的上述授权不附带任何明示或暗示的保证,不对任何人因从非阿里妈妈官方渠道或指定渠道下载、安装或使用阿里妈妈方圆体而引发的任何直接或间接损失承担责任。 
+6.  本声明的解释、履行与争议解决适用中华人民共和国的法律。用户与阿里妈妈就阿里妈妈方圆体的使用若发生争议,双方应友好协商,若协商不成,任一方有权向浙江省杭州市有管辖权的人民法院提起诉讼。 

+ 1 - 0
Client/res/icons/.gitkeep

@@ -0,0 +1 @@
+

BIN
Client/res/icons/app.ico


BIN
Client/res/icons/settings.png


+ 1 - 0
Client/res/images/.gitkeep

@@ -0,0 +1 @@
+

+ 190 - 0
Client/res/qss/app.qss

@@ -0,0 +1,190 @@
+* {
+    outline: 0;
+}
+QWidget {
+    font-family: "__APP_FONT__", "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
+    font-size: 13px;
+    color: #1A1A1A;
+}
+QMainWindow, QDialog {
+    background: #FFFFFF;
+}
+QToolTip {
+    background: #4C4C4C;
+    color: #FFFFFF;
+    border: 1px solid #4C4C4C;
+    padding: 4px 8px;
+    border-radius: 2px;
+}
+QLineEdit {
+    background: #FFFFFF;
+    border: 1px solid #D9D9D9;
+    border-radius: 2px;
+    padding: 7px 10px;
+    selection-background-color: #2E2E2E;
+    selection-color: #FFFFFF;
+    font-size: 13px;
+}
+QLineEdit:hover {
+    border: 1px solid #B5B5B5;
+}
+QLineEdit:focus {
+    border: 1px solid #2E2E2E;
+    background: #FFFFFF;
+}
+QLineEdit:disabled {
+    background: #F5F5F5;
+    color: #9A9A9A;
+}
+QTextEdit {
+    background: #FFFFFF;
+    border: none;
+    border-radius: 2px;
+    font-size: 14px;
+    padding: 8px 12px;
+    selection-background-color: #2E2E2E;
+    selection-color: #FFFFFF;
+}
+QPushButton {
+    border-radius: 2px;
+    font-size: 14px;
+}
+QPushButton[role="primary"] {
+    background: #2E2E2E;
+    color: #FFFFFF;
+    border: 1px solid #2E2E2E;
+    padding: 8px 16px;
+    font-size: 15px;
+}
+QPushButton[role="primary"]:hover {
+    background: #3E3E3E;
+    border-color: #3E3E3E;
+}
+QPushButton[role="primary"]:pressed {
+    background: #1A1A1A;
+    border-color: #1A1A1A;
+}
+QPushButton[role="primary"]:disabled {
+    background: #C8C8C8;
+    border-color: #C8C8C8;
+    color: #FFFFFF;
+}
+QPushButton[role="ghost"] {
+    background: #FFFFFF;
+    color: #1A1A1A;
+    border: 1px solid #D9D9D9;
+    padding: 7px 14px;
+}
+QPushButton[role="ghost"]:hover {
+    background: #F5F5F5;
+}
+QPushButton[role="ghost"]:pressed {
+    background: #EBEBEB;
+}
+QPushButton[role="link"] {
+    background: transparent;
+    color: #576B95;
+    border: none;
+    padding: 0;
+    font-size: 13px;
+    text-align: left;
+}
+QPushButton[role="link"]:hover {
+    color: #1A1A1A;
+}
+QPushButton[role="danger"] {
+    background: #FFFFFF;
+    color: #FA5151;
+    border: 1px solid #FA5151;
+    padding: 7px 14px;
+}
+QPushButton[role="danger"]:hover {
+    background: #FFF1F1;
+}
+QCheckBox {
+    spacing: 8px;
+    color: #666666;
+    font-size: 12px;
+}
+QCheckBox::indicator {
+    width: 14px;
+    height: 14px;
+    border: 1px solid #C6C6C6;
+    border-radius: 0px;
+    background: #FFFFFF;
+}
+QCheckBox::indicator:hover {
+    border: 1px solid #2E2E2E;
+}
+QCheckBox::indicator:checked {
+    background: #2E2E2E;
+    border: 1px solid #2E2E2E;
+    image: none;
+}
+QScrollArea {
+    border: none;
+    background: transparent;
+}
+QScrollBar:vertical {
+    width: 6px;
+    background: transparent;
+    margin: 0;
+    border-radius: 0px;
+}
+QScrollBar::handle:vertical {
+    background: #C8C8C8;
+    min-height: 32px;
+    border-radius: 0px;
+}
+QScrollBar::handle:vertical:hover {
+    background: #A8A8A8;
+}
+QScrollBar::add-line:vertical,
+QScrollBar::sub-line:vertical {
+    height: 0;
+    border: none;
+}
+QScrollBar::add-page:vertical,
+QScrollBar::sub-page:vertical {
+    background: transparent;
+}
+QScrollBar:horizontal {
+    height: 6px;
+    background: transparent;
+    margin: 0;
+}
+QScrollBar::handle:horizontal {
+    background: #C8C8C8;
+    min-width: 32px;
+}
+QScrollBar::add-line:horizontal,
+QScrollBar::sub-line:horizontal {
+    width: 0;
+    border: none;
+}
+QSplitter::handle:horizontal {
+    background: #E5E5E5;
+    width: 1px;
+}
+QMenu {
+    background: #FFFFFF;
+    border: 1px solid #E5E5E5;
+    padding: 4px 0;
+    border-radius: 0px;
+}
+QMenu::item {
+    padding: 8px 28px 8px 16px;
+    color: #1A1A1A;
+}
+QMenu::item:selected {
+    background: #F5F5F5;
+}
+QMenu::separator {
+    height: 1px;
+    background: #E5E5E5;
+    margin: 4px 0;
+}
+#ChromeWindow {
+    background: #FFFFFF;
+    border: 1px solid #E0E0E0;
+}

+ 8 - 0
Client/res/res.qrc

@@ -0,0 +1,8 @@
+<RCC>
+    <qresource prefix="/">
+        <file>qss/app.qss</file>
+        <file>icons/settings.png</file>
+        <file>icons/app.ico</file>
+        <file>sounds/notify.wav</file>
+    </qresource>
+</RCC>

BIN
Client/res/sounds/notify.wav


+ 52 - 0
Client/src/app/main.cpp

@@ -0,0 +1,52 @@
+#include "LoginWindow.h"
+#include "Client.h"
+#include "AppTheme.h"
+#include "AppConfig.h"
+#include "UserSettings.h"
+#include "UiHelpers.h"
+#include "Updater.h"
+
+#include <QtWidgets/QApplication>
+#include <QFont>
+
+int main(int argc, char *argv[])
+{
+#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
+    QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
+    QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
+#endif
+
+    int applyRc = 0;
+    if (Updater::runApplyUpdateIfRequested(argc, argv, &applyRc))
+        return applyRc;
+
+    QApplication app(argc, argv);
+    app.setQuitOnLastWindowClosed(false);
+    AppConfig::instance().load();
+    UserSettings::instance().load();
+    AppTheme::loadFonts();
+    app.setFont(QFont(AppTheme::fontFamily(), 9));
+    app.setStyleSheet(AppTheme::globalQss());
+    app.setApplicationName(QStringLiteral("NIM"));
+    app.setOrganizationName(QStringLiteral("NIM"));
+    app.setWindowIcon(UiHelpers::appIcon());
+
+    LoginWindow login;
+    QObject::connect(&login, &LoginWindow::loginSucceeded,
+                     [&](const QString &account, const QString &nickname) {
+        auto *mainWin = new Client(account, nickname);
+        mainWin->setAttribute(Qt::WA_DeleteOnClose);
+        QObject::connect(mainWin, &Client::loggedOut, &login, [&login]() {
+            login.prepareAfterLogout();
+            login.show();
+            login.raise();
+            login.activateWindow();
+        });
+        mainWin->show();
+        login.hide();
+    });
+
+    Updater::scheduleCleanupAfterStart();
+    login.show();
+    return app.exec();
+}

+ 99 - 0
Client/src/auth/ChangePasswordDialog.cpp

@@ -0,0 +1,99 @@
+#include "ChangePasswordDialog.h"
+#include "UiHelpers.h"
+#include "TitleBar.h"
+#include "AppConfig.h"
+#include "ImClient.h"
+
+#include <QJsonObject>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPointer>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+ChangePasswordDialog::ChangePasswordDialog(QWidget *parent)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("修改密码"));
+    setFixedSize(380, 360);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(QStringLiteral("修改密码"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *form = new QWidget(this);
+    auto *lay = new QVBoxLayout(form);
+    lay->setContentsMargins(28, 24, 28, 24);
+    lay->setSpacing(12);
+
+    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_confirm = UiHelpers::passwordEdit(QStringLiteral("确认新密码"), form);
+    m_confirm->setMaxLength(cfg.passwordMax());
+    m_error = UiHelpers::errorLabel(form);
+
+    m_submit = UiHelpers::primaryButton(QStringLiteral("确 定"), form);
+    m_submit->setMinimumWidth(0);
+
+    lay->addWidget(m_old);
+    lay->addWidget(m_password);
+    lay->addWidget(m_confirm);
+    lay->addWidget(m_error);
+    lay->addSpacing(8);
+    lay->addWidget(m_submit);
+    lay->addStretch();
+
+    root->addWidget(form, 1);
+
+    connect(m_submit, &QPushButton::clicked, this, &ChangePasswordDialog::onSubmit);
+    connect(m_confirm, &QLineEdit::returnPressed, this, &ChangePasswordDialog::onSubmit);
+}
+
+void ChangePasswordDialog::onSubmit()
+{
+    if (m_old->text().isEmpty() || m_password->text().isEmpty() || m_confirm->text().isEmpty())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请完整填写密码"));
+        return;
+    }
+    if (!AppConfig::instance().validPassword(m_password->text()))
+    {
+        const auto &cfg = AppConfig::instance();
+        UiHelpers::setError(m_error,
+            QStringLiteral("新密码须为 %1-%2 位").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
+        return;
+    }
+    if (m_password->text() != m_confirm->text())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("两次输入的新密码不一致"));
+        return;
+    }
+    UiHelpers::setError(m_error, QString());
+    m_submit->setEnabled(false);
+    QPointer<ChangePasswordDialog> self(this);
+    ImClient::instance().changePassword(m_old->text(), m_password->text(),
+        [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            self->m_submit->setEnabled(true);
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+                return;
+            }
+            self->accept();
+        });
+}

+ 23 - 0
Client/src/auth/ChangePasswordDialog.h

@@ -0,0 +1,23 @@
+#pragma once
+
+#include <QDialog>
+
+class QLineEdit;
+class QLabel;
+
+class ChangePasswordDialog : public QDialog
+{
+    Q_OBJECT
+
+public:
+    explicit ChangePasswordDialog(QWidget *parent = nullptr);
+
+private:
+    void onSubmit();
+
+    QLineEdit *m_old = nullptr;
+    QLineEdit *m_password = nullptr;
+    QLineEdit *m_confirm = nullptr;
+    QLabel *m_error = nullptr;
+    QPushButton *m_submit = nullptr;
+};

+ 170 - 0
Client/src/auth/ForgotPasswordDialog.cpp

@@ -0,0 +1,170 @@
+#include "ForgotPasswordDialog.h"
+#include "UiHelpers.h"
+#include "AppConfig.h"
+#include "ImClient.h"
+
+#include <QJsonObject>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPointer>
+#include <QPushButton>
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+
+ForgotPasswordDialog::ForgotPasswordDialog(QWidget *parent)
+    : QWidget(parent)
+{
+    auto *lay = new QVBoxLayout(this);
+    lay->setContentsMargins(36, 22, 36, 24);
+    lay->setSpacing(10);
+
+    m_uidEdit = UiHelpers::lineEdit(QStringLiteral("用户 ID"), this);
+    m_phone = UiHelpers::lineEdit(QStringLiteral("绑定手机号"), this);
+
+    auto *codeRow = new QHBoxLayout();
+    codeRow->setSpacing(8);
+    m_code = UiHelpers::lineEdit(QStringLiteral("验证码"), this);
+    m_getCode = UiHelpers::ghostButton(QStringLiteral("获取验证码"), this);
+    m_getCode->setFixedWidth(108);
+    m_getCode->setFixedHeight(36);
+    codeRow->addWidget(m_code, 1);
+    codeRow->addWidget(m_getCode);
+
+    m_password = UiHelpers::passwordEdit(QStringLiteral("新密码"), this);
+    m_confirm = UiHelpers::passwordEdit(QStringLiteral("确认新密码"), this);
+    m_error = UiHelpers::errorLabel(this);
+
+    m_submit = UiHelpers::primaryButton(QStringLiteral("重置密码"), this);
+    m_submit->setMinimumWidth(0);
+
+    auto *back = UiHelpers::linkButton(QStringLiteral("返回登录"), this);
+
+    lay->addWidget(m_uidEdit);
+    lay->addWidget(m_phone);
+    lay->addLayout(codeRow);
+    lay->addWidget(m_password);
+    lay->addWidget(m_confirm);
+    lay->addWidget(m_error);
+    lay->addSpacing(4);
+    lay->addWidget(m_submit);
+    lay->addWidget(back, 0, Qt::AlignHCenter);
+    lay->addStretch();
+
+    applyLimits();
+
+    connect(m_getCode, &QPushButton::clicked, this, &ForgotPasswordDialog::onGetCode);
+    connect(m_submit, &QPushButton::clicked, this, &ForgotPasswordDialog::onSubmit);
+    connect(back, &QPushButton::clicked, this, &ForgotPasswordDialog::backRequested);
+}
+
+void ForgotPasswordDialog::resetForm()
+{
+    m_uidEdit->clear();
+    m_phone->clear();
+    m_code->clear();
+    m_password->clear();
+    m_confirm->clear();
+    UiHelpers::setError(m_error, QString());
+    setBusy(false);
+    m_uidEdit->setFocus();
+}
+
+void ForgotPasswordDialog::applyLimits()
+{
+    const auto &cfg = AppConfig::instance();
+    if (m_phone)
+        m_phone->setMaxLength(cfg.phoneLen());
+    if (m_code)
+        m_code->setMaxLength(cfg.codeLen());
+    if (m_password)
+    {
+        m_password->setPlaceholderText(
+            QStringLiteral("新密码(%1-%2 位)").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
+        m_password->setMaxLength(cfg.passwordMax());
+    }
+    if (m_confirm)
+        m_confirm->setMaxLength(cfg.passwordMax());
+}
+
+void ForgotPasswordDialog::setBusy(bool on)
+{
+    m_getCode->setEnabled(!on);
+    m_submit->setEnabled(!on);
+}
+
+void ForgotPasswordDialog::onGetCode()
+{
+    const auto &cfg = AppConfig::instance();
+    if (!cfg.validPhone(m_phone->text().trimmed()))
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请填写正确的手机号"));
+        return;
+    }
+    UiHelpers::setError(m_error, QString());
+    setBusy(true);
+    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))
+            {
+                UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+                return;
+            }
+            const QString sms = reply.value(QStringLiteral("sms_code")).toString();
+            if (!sms.isEmpty())
+                self->m_code->setText(sms);
+            UiHelpers::setError(self->m_error, QString());
+            UiHelpers::showToast(self->window(), QStringLiteral("验证码已发送"));
+        });
+}
+
+void ForgotPasswordDialog::onSubmit()
+{
+    const auto &cfg = AppConfig::instance();
+    const QString password = m_password->text();
+    bool uidOk = false;
+    const qint64 uid = m_uidEdit->text().trimmed().toLongLong(&uidOk);
+    if (!uidOk || uid <= 0
+        || m_phone->text().trimmed().isEmpty()
+        || m_code->text().trimmed().isEmpty()
+        || password.isEmpty())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请完整填写信息"));
+        return;
+    }
+    if (!cfg.validPhone(m_phone->text().trimmed()))
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请填写正确的手机号"));
+        return;
+    }
+    if (!cfg.validPassword(password))
+    {
+        UiHelpers::setError(m_error,
+            QStringLiteral("密码须为 %1-%2 位").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
+        return;
+    }
+    if (password != m_confirm->text())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("两次输入的密码不一致"));
+        return;
+    }
+
+    UiHelpers::setError(m_error, QString());
+    setBusy(true);
+    QPointer<ForgotPasswordDialog> self(this);
+    ImClient::instance().resetPassword(uid, m_phone->text().trimmed(),
+        m_code->text().trimmed(), password, [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            self->setBusy(false);
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+                return;
+            }
+            emit self->submitted();
+        });
+}

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

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <QWidget>
+
+class QLineEdit;
+class QLabel;
+class QPushButton;
+
+class ForgotPasswordDialog : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit ForgotPasswordDialog(QWidget *parent = nullptr);
+
+    void applyLimits();
+    void resetForm();
+
+signals:
+    void submitted();
+    void backRequested();
+
+private:
+    void onGetCode();
+    void onSubmit();
+    void setBusy(bool on);
+
+    QLineEdit *m_uidEdit = nullptr;
+    QLineEdit *m_phone = nullptr;
+    QLineEdit *m_code = nullptr;
+    QLineEdit *m_password = nullptr;
+    QLineEdit *m_confirm = nullptr;
+    QLabel *m_error = nullptr;
+    QPushButton *m_getCode = nullptr;
+    QPushButton *m_submit = nullptr;
+};

+ 319 - 0
Client/src/auth/LoginWindow.cpp

@@ -0,0 +1,319 @@
+#include "LoginWindow.h"
+#include "RegisterDialog.h"
+#include "ForgotPasswordDialog.h"
+#include "UiHelpers.h"
+#include "TitleBar.h"
+#include "FramelessHelper.h"
+#include "UserSettings.h"
+#include "AppConfig.h"
+#include "ImClient.h"
+#include "Protocol.h"
+#include "Updater.h"
+
+#include <QApplication>
+#include <QCheckBox>
+#include <QCloseEvent>
+#include <QEasingCurve>
+#include <QJsonObject>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPointer>
+#include <QPropertyAnimation>
+#include <QPushButton>
+#include <QShowEvent>
+#include <QStackedWidget>
+#include <QTimer>
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+
+LoginWindow::LoginWindow(QWidget *parent)
+    : QWidget(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("NIM"));
+    setFixedSize(350, 400);
+    setWindowFlags(Qt::Window | Qt::FramelessWindowHint);
+    setWindowIcon(UiHelpers::appIcon());
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    m_titleBar = new TitleBar(this);
+    m_titleBar->setTitle(QStringLiteral("NIM"));
+    m_titleBar->setTheme(TitleBar::Light);
+    m_titleBar->setMaximizable(false);
+    m_titleBar->bind(this, false);
+    root->addWidget(m_titleBar);
+
+    m_header = UiHelpers::authHeader(QStringLiteral("即时通讯"), this);
+    root->addWidget(m_header);
+
+    m_stack = new QStackedWidget(this);
+    m_registerPage = new RegisterDialog(m_stack);
+    m_resetPage = new ForgotPasswordDialog(m_stack);
+    m_stack->addWidget(buildLoginPage());
+    m_stack->addWidget(m_registerPage);
+    m_stack->addWidget(m_resetPage);
+    root->addWidget(m_stack, 1);
+
+    connect(m_registerPage, &RegisterDialog::submitted, this, &LoginWindow::onRegisterSubmitted);
+    connect(m_registerPage, &RegisterDialog::backRequested, this, [this]() { showPage(PageLogin); });
+    connect(m_resetPage, &ForgotPasswordDialog::submitted, this, &LoginWindow::onResetSubmitted);
+    connect(m_resetPage, &ForgotPasswordDialog::backRequested, this, [this]() { showPage(PageLogin); });
+
+    applySavedLogin();
+    connect(&ImClient::instance(), &ImClient::limitsUpdated, this, &LoginWindow::applyServerLimits);
+    applyServerLimits();
+    ImClient::instance().connectToServer();
+    tryAutoLogin();
+}
+
+QWidget *LoginWindow::buildLoginPage()
+{
+    auto *form = new QWidget(this);
+    auto *lay = new QVBoxLayout(form);
+    lay->setContentsMargins(36, 28, 36, 28);
+    lay->setSpacing(12);
+
+    m_account = UiHelpers::lineEdit(QStringLiteral("账号"), form);
+    m_password = UiHelpers::passwordEdit(QStringLiteral("密码"), form);
+
+    auto *opts = new QHBoxLayout();
+    opts->setContentsMargins(0, 2, 0, 0);
+    m_remember = new QCheckBox(QStringLiteral("记住密码"), form);
+    m_autoLogin = new QCheckBox(QStringLiteral("自动登录"), form);
+    opts->addWidget(m_remember);
+    opts->addStretch();
+    opts->addWidget(m_autoLogin);
+
+    m_error = UiHelpers::errorLabel(form);
+
+    m_loginBtn = UiHelpers::primaryButton(QStringLiteral("登 录"), form);
+    m_loginBtn->setMinimumWidth(0);
+
+    auto *links = new QHBoxLayout();
+    auto *reg = UiHelpers::linkButton(QStringLiteral("注册账号"), form);
+    auto *forgot = UiHelpers::linkButton(QStringLiteral("忘记密码"), form);
+    links->addWidget(reg);
+    links->addStretch();
+    links->addWidget(forgot);
+
+    lay->addWidget(m_account);
+    lay->addWidget(m_password);
+    lay->addLayout(opts);
+    lay->addWidget(m_error);
+    lay->addSpacing(6);
+    lay->addWidget(m_loginBtn);
+    lay->addLayout(links);
+    lay->addStretch();
+
+    connect(m_loginBtn, &QPushButton::clicked, this, &LoginWindow::onLogin);
+    connect(m_password, &QLineEdit::returnPressed, this, &LoginWindow::onLogin);
+    connect(m_account, &QLineEdit::returnPressed, this, [this]() { m_password->setFocus(); });
+    connect(reg, &QPushButton::clicked, this, [this]() { showPage(PageRegister); });
+    connect(forgot, &QPushButton::clicked, this, [this]() { showPage(PageReset); });
+    connect(m_autoLogin, &QCheckBox::toggled, this, [this](bool on) {
+        if (on)
+            m_remember->setChecked(true);
+    });
+    connect(m_remember, &QCheckBox::toggled, this, [this](bool on) {
+        if (!on)
+            m_autoLogin->setChecked(false);
+    });
+
+    return form;
+}
+
+void LoginWindow::showPage(Page page)
+{
+    m_stack->setCurrentIndex(page);
+    switch (page)
+    {
+    case PageRegister:
+        m_titleBar->setTitle(QStringLiteral("注册账号"));
+        UiHelpers::setAuthHeaderSubtitle(m_header, QStringLiteral("创建你的账号"));
+        m_registerPage->resetForm();
+        animateToHeight(580);
+        break;
+    case PageReset:
+        m_titleBar->setTitle(QStringLiteral("找回密码"));
+        UiHelpers::setAuthHeaderSubtitle(m_header, QStringLiteral("重置登录密码"));
+        m_resetPage->resetForm();
+        animateToHeight(540);
+        break;
+    case PageLogin:
+    default:
+        m_titleBar->setTitle(QStringLiteral("NIM"));
+        UiHelpers::setAuthHeaderSubtitle(m_header, QStringLiteral("即时通讯"));
+        m_account->setFocus();
+        animateToHeight(400);
+        break;
+    }
+}
+
+void LoginWindow::animateToHeight(int h)
+{
+    if (height() == h && minimumHeight() == h && maximumHeight() == h)
+        return;
+    if (m_heightAnim)
+    {
+        m_heightAnim->stop();
+        m_heightAnim = nullptr;
+    }
+
+    setMinimumWidth(350);
+    setMaximumWidth(350);
+    setMinimumHeight(qMin(height(), h));
+    setMaximumHeight(qMax(height(), h));
+
+    auto *anim = new QPropertyAnimation(this, "geometry", this);
+    anim->setDuration(220);
+    anim->setEasingCurve(QEasingCurve::OutCubic);
+    const QRect from = geometry();
+    QRect to = from;
+    to.setHeight(h);
+    anim->setStartValue(from);
+    anim->setEndValue(to);
+    connect(anim, &QPropertyAnimation::finished, this, [this, h]() {
+        setFixedSize(350, h);
+        m_heightAnim = nullptr;
+    });
+    m_heightAnim = anim;
+    anim->start(QAbstractAnimation::DeleteWhenStopped);
+}
+
+void LoginWindow::showEvent(QShowEvent *event)
+{
+    QWidget::showEvent(event);
+    const int h = (m_stack && m_stack->currentIndex() == PageRegister) ? 580
+                : (m_stack && m_stack->currentIndex() == PageReset) ? 540
+                : 400;
+    setFixedSize(350, h);
+    if (m_titleBar && m_titleBar->framelessHelper())
+        m_titleBar->framelessHelper()->fixupSize();
+    QTimer::singleShot(0, this, [this, h]() {
+        if (m_heightAnim)
+            return;
+        setFixedSize(350, h);
+        if (m_titleBar && m_titleBar->framelessHelper())
+            m_titleBar->framelessHelper()->fixupSize();
+    });
+}
+
+void LoginWindow::closeEvent(QCloseEvent *event)
+{
+    QApplication::quit();
+    QWidget::closeEvent(event);
+}
+
+QString LoginWindow::lastAccount() const
+{
+    return m_account ? m_account->text().trimmed() : QString();
+}
+
+void LoginWindow::prepareAfterLogout()
+{
+    m_skipAutoLogin = true;
+    showPage(PageLogin);
+}
+
+void LoginWindow::applySavedLogin()
+{
+    const auto &u = UserSettings::instance();
+    m_account->setText(u.lastAccount());
+    m_remember->setChecked(u.rememberPassword());
+    m_autoLogin->setChecked(u.autoLogin());
+    if (u.rememberPassword())
+        m_password->setText(u.lastPassword());
+    else
+        m_password->clear();
+
+    if (u.lastAccount().isEmpty())
+        m_account->setFocus();
+    else if (!u.rememberPassword() || u.lastPassword().isEmpty())
+        m_password->setFocus();
+}
+
+void LoginWindow::applyServerLimits()
+{
+    const auto &cfg = AppConfig::instance();
+    if (m_account)
+        m_account->setMaxLength(cfg.accountMax());
+    if (m_password)
+        m_password->setMaxLength(cfg.passwordMax());
+    if (m_registerPage)
+        m_registerPage->applyLimits();
+    if (m_resetPage)
+        m_resetPage->applyLimits();
+    Updater::promptIfNeeded(this);
+}
+
+void LoginWindow::tryAutoLogin()
+{
+    if (m_skipAutoLogin)
+        return;
+    const auto &u = UserSettings::instance();
+    if (!u.autoLogin() || !u.rememberPassword())
+        return;
+    if (m_account->text().trimmed().isEmpty() || m_password->text().isEmpty())
+        return;
+    QTimer::singleShot(0, this, &LoginWindow::onLogin);
+}
+
+void LoginWindow::onLogin()
+{
+    if (m_loggingIn)
+        return;
+    UiHelpers::setError(m_error, QString());
+    const QString account = m_account->text().trimmed();
+    const QString password = m_password->text();
+    if (account.isEmpty() || password.isEmpty())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请输入账号和密码"));
+        return;
+    }
+
+    m_loggingIn = true;
+    m_loginBtn->setEnabled(false);
+    QPointer<LoginWindow> self(this);
+    ImClient::instance().login(account, password, [self, account, password](const QJsonObject &reply) {
+        if (!self)
+            return;
+        self->m_loggingIn = false;
+        self->m_loginBtn->setEnabled(true);
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+            return;
+        }
+        const QString nickname = reply.value(QStringLiteral("nickname")).toString();
+        const QString savedAccount = reply.value(QStringLiteral("account")).toString();
+        const QString acc = savedAccount.isEmpty() ? account : savedAccount;
+        UserSettings::instance().saveLogin(acc,
+                                           nickname.isEmpty() ? acc : nickname,
+                                           password,
+                                           self->m_remember->isChecked(),
+                                           self->m_autoLogin->isChecked());
+        emit self->loginSucceeded(acc, nickname.isEmpty() ? acc : nickname);
+    });
+}
+
+void LoginWindow::onRegisterSubmitted(qint64 /*uid*/)
+{
+    m_account->setText(m_registerPage->account());
+    m_password->clear();
+    UiHelpers::setError(m_error, QString());
+    showPage(PageLogin);
+    m_password->setFocus();
+    UiHelpers::showToast(this, QStringLiteral("注册成功,请使用账号登录"));
+}
+
+void LoginWindow::onResetSubmitted()
+{
+    m_password->clear();
+    UiHelpers::setError(m_error, QString());
+    showPage(PageLogin);
+    m_password->setFocus();
+    UiHelpers::showToast(this, QStringLiteral("密码已重置,请登录"));
+}

+ 67 - 0
Client/src/auth/LoginWindow.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <QWidget>
+#include <QCloseEvent>
+
+class QShowEvent;
+
+class QLineEdit;
+class QCheckBox;
+class QLabel;
+class QPushButton;
+class QStackedWidget;
+class QPropertyAnimation;
+class TitleBar;
+class RegisterDialog;
+class ForgotPasswordDialog;
+
+class LoginWindow : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit LoginWindow(QWidget *parent = nullptr);
+    QString lastAccount() const;
+    void prepareAfterLogout();
+
+signals:
+    void loginSucceeded(const QString &account, const QString &nickname);
+
+protected:
+    void closeEvent(QCloseEvent *event) override;
+    void showEvent(QShowEvent *event) override;
+
+private:
+    enum Page
+    {
+        PageLogin = 0,
+        PageRegister,
+        PageReset
+    };
+
+    QWidget *buildLoginPage();
+    void showPage(Page page);
+    void animateToHeight(int h);
+    void applySavedLogin();
+    void applyServerLimits();
+    void tryAutoLogin();
+    void onLogin();
+    void onRegisterSubmitted(qint64 uid);
+    void onResetSubmitted();
+
+    TitleBar *m_titleBar = nullptr;
+    QWidget *m_header = nullptr;
+    QStackedWidget *m_stack = nullptr;
+    RegisterDialog *m_registerPage = nullptr;
+    ForgotPasswordDialog *m_resetPage = nullptr;
+
+    QLineEdit *m_account = nullptr;
+    QLineEdit *m_password = nullptr;
+    QCheckBox *m_remember = nullptr;
+    QCheckBox *m_autoLogin = nullptr;
+    QLabel *m_error = nullptr;
+    QPushButton *m_loginBtn = nullptr;
+    QPropertyAnimation *m_heightAnim = nullptr;
+    bool m_skipAutoLogin = false;
+    bool m_loggingIn = false;
+};

+ 206 - 0
Client/src/auth/RegisterDialog.cpp

@@ -0,0 +1,206 @@
+#include "RegisterDialog.h"
+#include "UiHelpers.h"
+#include "AppConfig.h"
+#include "ImClient.h"
+#include "Protocol.h"
+
+#include <QJsonObject>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPointer>
+#include <QPushButton>
+#include <QVBoxLayout>
+#include <QHBoxLayout>
+
+RegisterDialog::RegisterDialog(QWidget *parent)
+    : QWidget(parent)
+{
+    auto *lay = new QVBoxLayout(this);
+    lay->setContentsMargins(36, 22, 36, 24);
+    lay->setSpacing(10);
+
+    m_nickname = UiHelpers::lineEdit(QStringLiteral("昵称"), this);
+    m_account = UiHelpers::lineEdit(QStringLiteral("账号"), this);
+    m_phone = UiHelpers::lineEdit(QStringLiteral("手机号"), this);
+
+    auto *codeRow = new QHBoxLayout();
+    codeRow->setSpacing(8);
+    m_code = UiHelpers::lineEdit(QStringLiteral("验证码"), this);
+    m_getCode = UiHelpers::ghostButton(QStringLiteral("获取验证码"), this);
+    m_getCode->setFixedWidth(108);
+    m_getCode->setFixedHeight(36);
+    codeRow->addWidget(m_code, 1);
+    codeRow->addWidget(m_getCode);
+
+    m_password = UiHelpers::passwordEdit(QStringLiteral("密码"), this);
+    m_confirm = UiHelpers::passwordEdit(QStringLiteral("确认密码"), this);
+    m_error = UiHelpers::errorLabel(this);
+
+    m_submit = UiHelpers::primaryButton(QStringLiteral("注 册"), this);
+    m_submit->setMinimumWidth(0);
+
+    auto *back = UiHelpers::linkButton(QStringLiteral("已有账号,去登录"), this);
+
+    lay->addWidget(m_nickname);
+    lay->addWidget(m_account);
+    lay->addWidget(m_phone);
+    lay->addLayout(codeRow);
+    lay->addWidget(m_password);
+    lay->addWidget(m_confirm);
+    lay->addWidget(m_error);
+    lay->addSpacing(4);
+    lay->addWidget(m_submit);
+    lay->addWidget(back, 0, Qt::AlignHCenter);
+    lay->addStretch();
+
+    applyLimits();
+
+    connect(m_getCode, &QPushButton::clicked, this, &RegisterDialog::onGetCode);
+    connect(m_submit, &QPushButton::clicked, this, &RegisterDialog::onSubmit);
+    connect(back, &QPushButton::clicked, this, &RegisterDialog::backRequested);
+}
+
+QString RegisterDialog::account() const
+{
+    return m_account->text().trimmed();
+}
+
+QString RegisterDialog::nickname() const
+{
+    return m_nickname->text().trimmed();
+}
+
+void RegisterDialog::applyLimits()
+{
+    const auto &cfg = AppConfig::instance();
+    if (m_nickname)
+        m_nickname->setMaxLength(cfg.nicknameMax());
+    if (m_account)
+    {
+        m_account->setPlaceholderText(
+            QStringLiteral("账号(%1-%2 位字母或数字)").arg(cfg.accountMin()).arg(cfg.accountMax()));
+        m_account->setMaxLength(cfg.accountMax());
+    }
+    if (m_phone)
+        m_phone->setMaxLength(cfg.phoneLen());
+    if (m_code)
+        m_code->setMaxLength(cfg.codeLen());
+    if (m_password)
+    {
+        m_password->setPlaceholderText(
+            QStringLiteral("密码(%1-%2 位)").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
+        m_password->setMaxLength(cfg.passwordMax());
+    }
+    if (m_confirm)
+        m_confirm->setMaxLength(cfg.passwordMax());
+}
+
+void RegisterDialog::resetForm()
+{
+    m_uid = 0;
+    m_nickname->clear();
+    m_account->clear();
+    m_phone->clear();
+    m_code->clear();
+    m_password->clear();
+    m_confirm->clear();
+    UiHelpers::setError(m_error, QString());
+    setBusy(false);
+    m_nickname->setFocus();
+}
+
+void RegisterDialog::setBusy(bool on)
+{
+    m_getCode->setEnabled(!on);
+    m_submit->setEnabled(!on);
+}
+
+void RegisterDialog::onGetCode()
+{
+    const auto &cfg = AppConfig::instance();
+    const QString phone = m_phone->text().trimmed();
+    if (!cfg.validPhone(phone))
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请填写正确的手机号"));
+        return;
+    }
+    UiHelpers::setError(m_error, QString());
+    setBusy(true);
+    QPointer<RegisterDialog> self(this);
+    ImClient::instance().sendCode(phone, QStringLiteral("register"), [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        self->setBusy(false);
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+            return;
+        }
+        const QString sms = reply.value(QStringLiteral("sms_code")).toString();
+        if (!sms.isEmpty())
+            self->m_code->setText(sms);
+        UiHelpers::setError(self->m_error, QString());
+        UiHelpers::showToast(self->window(), QStringLiteral("验证码已发送"));
+    });
+}
+
+void RegisterDialog::onSubmit()
+{
+    const auto &cfg = AppConfig::instance();
+    const QString nickname = m_nickname->text().trimmed();
+    const QString account = m_account->text().trimmed();
+    const QString phone = m_phone->text().trimmed();
+    const QString code = m_code->text().trimmed();
+    const QString password = m_password->text();
+
+    if (nickname.isEmpty() || account.isEmpty() || phone.isEmpty()
+        || code.isEmpty() || password.isEmpty())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请完整填写注册信息"));
+        return;
+    }
+    if (!cfg.validNickname(nickname))
+    {
+        UiHelpers::setError(m_error, QStringLiteral("昵称过长"));
+        return;
+    }
+    if (!cfg.validAccount(account))
+    {
+        UiHelpers::setError(m_error,
+            QStringLiteral("账号须为 %1-%2 位字母或数字").arg(cfg.accountMin()).arg(cfg.accountMax()));
+        return;
+    }
+    if (!cfg.validPhone(phone))
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请填写正确的手机号"));
+        return;
+    }
+    if (!cfg.validPassword(password))
+    {
+        UiHelpers::setError(m_error,
+            QStringLiteral("密码须为 %1-%2 位").arg(cfg.passwordMin()).arg(cfg.passwordMax()));
+        return;
+    }
+    if (password != m_confirm->text())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("两次输入的密码不一致"));
+        return;
+    }
+
+    UiHelpers::setError(m_error, QString());
+    setBusy(true);
+    QPointer<RegisterDialog> self(this);
+    ImClient::instance().registerUser(nickname, account, phone, code, password,
+        [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            self->setBusy(false);
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+                return;
+            }
+            self->m_uid = Proto::jsonI64(reply, QStringLiteral("uid"));
+            emit self->submitted(self->m_uid);
+        });
+}

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

@@ -0,0 +1,41 @@
+#pragma once
+
+#include <QWidget>
+
+class QLineEdit;
+class QLabel;
+class QPushButton;
+
+class RegisterDialog : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit RegisterDialog(QWidget *parent = nullptr);
+
+    QString account() const;
+    QString nickname() const;
+    qint64 uid() const { return m_uid; }
+    void applyLimits();
+    void resetForm();
+
+signals:
+    void submitted(qint64 uid);
+    void backRequested();
+
+private:
+    void onGetCode();
+    void onSubmit();
+    void setBusy(bool on);
+
+    QLineEdit *m_nickname = nullptr;
+    QLineEdit *m_account = nullptr;
+    QLineEdit *m_phone = nullptr;
+    QLineEdit *m_code = nullptr;
+    QLineEdit *m_password = nullptr;
+    QLineEdit *m_confirm = nullptr;
+    QLabel *m_error = nullptr;
+    QPushButton *m_getCode = nullptr;
+    QPushButton *m_submit = nullptr;
+    qint64 m_uid = 0;
+};

+ 1 - 0
Client/src/chat/.gitkeep

@@ -0,0 +1 @@
+

+ 241 - 0
Client/src/chat/AvatarCache.cpp

@@ -0,0 +1,241 @@
+#include "AvatarCache.h"
+#include "AppConfig.h"
+#include "HttpFetch.h"
+#include "ImClient.h"
+#include "LocalStore.h"
+#include "Protocol.h"
+#include "UiHelpers.h"
+
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QLabel>
+#include <QPointer>
+#include <QVariant>
+
+AvatarCache &AvatarCache::instance()
+{
+    static AvatarCache s;
+    return s;
+}
+
+AvatarCache::AvatarCache(QObject *parent)
+    : QObject(parent)
+{
+}
+
+QString AvatarCache::cacheDir() const
+{
+    return LocalStore::userSubdir(ImClient::instance().uid(), QStringLiteral("avatars"));
+}
+
+QString AvatarCache::filePath(qint64 uid, int ver) const
+{
+    const QString dir = cacheDir();
+    if (dir.isEmpty())
+        return {};
+    return dir + QStringLiteral("/%1_%2.jpg").arg(uid).arg(ver);
+}
+
+int AvatarCache::diskVer(qint64 uid) const
+{
+    QDir dir(cacheDir());
+    int best = 0;
+    const QString prefix = QString::number(uid) + QLatin1Char('_');
+    const QStringList files = dir.entryList(QStringList() << (prefix + QStringLiteral("*.jpg")), QDir::Files);
+    for (const QString &f : files)
+    {
+        const QString stem = QFileInfo(f).completeBaseName();
+        const int us = stem.lastIndexOf(QLatin1Char('_'));
+        if (us < 0)
+            continue;
+        bool ok = false;
+        const int v = stem.mid(us + 1).toInt(&ok);
+        if (ok && v > best)
+            best = v;
+    }
+    return best;
+}
+
+int AvatarCache::resolveVer(qint64 uid, int ver)
+{
+    int best = m_ver.value(uid, 0);
+    if (best <= 0)
+    {
+        best = diskVer(uid);
+        if (best > 0)
+            m_ver.insert(uid, best);
+    }
+    if (ver > best)
+        return ver;
+    if (best > 0)
+        return best;
+    return ver;
+}
+
+void AvatarCache::invalidate(qint64 uid)
+{
+    const QString prefix = QString::number(uid) + QLatin1Char('_');
+    for (auto it = m_pix.begin(); it != m_pix.end(); )
+    {
+        if (it.key().startsWith(prefix) || it.key().startsWith(QString::number(uid) + QLatin1Char('/')))
+            it = m_pix.erase(it);
+        else
+            ++it;
+    }
+    m_ver.remove(uid);
+}
+
+void AvatarCache::remember(qint64 uid, int ver, const QByteArray &bytes)
+{
+    if (uid <= 0 || ver <= 0 || bytes.isEmpty())
+        return;
+    const QString path = filePath(uid, ver);
+    QDir().mkpath(QFileInfo(path).absolutePath());
+    QFile f(path);
+    if (f.open(QIODevice::WriteOnly))
+    {
+        f.write(bytes);
+        f.flush();
+        f.close();
+    }
+    invalidate(uid);
+    m_ver.insert(uid, ver);
+    emit updated(uid);
+}
+
+void AvatarCache::ensure(qint64 uid, int ver)
+{
+    if (uid <= 0 || ver <= 0 || m_fetching.contains(uid))
+        return;
+    int have = m_ver.value(uid, 0);
+    if (have <= 0)
+        have = diskVer(uid);
+    if (have >= ver && QFile::exists(filePath(uid, have)))
+        return;
+    if (QFile::exists(filePath(uid, ver)))
+        return;
+    m_fetching.insert(uid);
+    QPointer<AvatarCache> self(this);
+    auto takeHttp = [self, uid](const QString &path, int gotVer) {
+        if (!self)
+            return;
+        const QString http = AppConfig::instance().resourceUrl(path);
+        if (http.isEmpty())
+        {
+            self->m_fetching.remove(uid);
+            return;
+        }
+        HttpFetch::instance().get(http, [self, uid, gotVer](bool ok, const QByteArray &bytes, const QString &) {
+            if (!self)
+                return;
+            if (ok && !bytes.isEmpty() && gotVer > 0)
+                self->remember(uid, gotVer, bytes);
+            self->m_fetching.remove(uid);
+        });
+    };
+    ImClient::instance().getAvatar(uid, [self, uid, ver, takeHttp](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            self->m_fetching.remove(uid);
+            return;
+        }
+        int got = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("ver")));
+        if (got <= 0)
+            got = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("avatar_ver")));
+        if (got <= 0)
+            got = ver;
+        const QString url = reply.value(QStringLiteral("url")).toString();
+        if (!url.isEmpty())
+        {
+            takeHttp(url, got);
+            return;
+        }
+        const QByteArray bytes = QByteArray::fromBase64(
+            reply.value(QStringLiteral("data")).toString().toLatin1());
+        if (!bytes.isEmpty() && got > 0)
+            self->remember(uid, got, bytes);
+        self->m_fetching.remove(uid);
+    });
+}
+
+void AvatarCache::probe(qint64 uid)
+{
+    if (uid <= 0 || m_probing.contains(uid) || m_fetching.contains(uid))
+        return;
+    if (resolveVer(uid, 0) > 0)
+        return;
+    m_probing.insert(uid);
+    QPointer<AvatarCache> self(this);
+    ImClient::instance().getProfile(uid, [self, uid](const QJsonObject &reply) {
+        if (!self)
+            return;
+        self->m_probing.remove(uid);
+        if (!ImClient::ok(reply))
+            return;
+        const int ver = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("avatar_ver")));
+        if (ver <= 0)
+            return;
+        self->m_ver.insert(uid, ver);
+        self->ensure(uid, ver);
+        emit self->updated(uid);
+    });
+}
+
+void AvatarCache::applyTo(QLabel *label, qint64 uid, int ver, const QString &name, int size)
+{
+    if (!label)
+        return;
+    label->setAlignment(Qt::AlignCenter);
+    label->setProperty("avatarUid", QVariant::fromValue(uid));
+    label->setProperty("avatarVer", resolveVer(uid, ver));
+    label->setProperty("avatarName", name);
+    label->setProperty("avatarSize", size);
+    if (!label->property("avatarBound").toBool())
+    {
+        label->setProperty("avatarBound", true);
+        connect(this, &AvatarCache::updated, label, [this, label](qint64 id) {
+            if (!label || id != label->property("avatarUid").toLongLong())
+                return;
+            const qint64 u = label->property("avatarUid").toLongLong();
+            const int v = resolveVer(u, label->property("avatarVer").toInt());
+            label->setProperty("avatarVer", v);
+            const QString n = label->property("avatarName").toString();
+            const int s = label->property("avatarSize").toInt();
+            label->setPixmap(pixmap(u, v, n, s));
+        });
+    }
+    label->setPixmap(pixmap(uid, ver, name, size));
+    if (uid > 0 && resolveVer(uid, ver) <= 0)
+        probe(uid);
+}
+
+QPixmap AvatarCache::pixmap(qint64 uid, int ver, const QString &name, int size)
+{
+    ver = resolveVer(uid, ver);
+    if (uid <= 0 || ver <= 0)
+        return UiHelpers::avatarPixmap(name, size);
+    const QString key = QStringLiteral("%1/%2/%3").arg(uid).arg(ver).arg(size);
+    if (m_pix.contains(key))
+        return m_pix.value(key);
+    const QString path = filePath(uid, ver);
+    QPixmap disk(path);
+    if (disk.isNull())
+    {
+        QFile f(path);
+        if (f.open(QIODevice::ReadOnly))
+            disk.loadFromData(f.readAll(), "JPEG");
+    }
+    if (disk.isNull())
+    {
+        ensure(uid, ver);
+        return UiHelpers::avatarPixmap(name, size);
+    }
+    const QPixmap rounded = UiHelpers::roundAvatar(disk, size);
+    m_pix.insert(key, rounded);
+    m_ver.insert(uid, ver);
+    return rounded;
+}

+ 40 - 0
Client/src/chat/AvatarCache.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#include <QHash>
+#include <QObject>
+#include <QPixmap>
+#include <QSet>
+#include <QString>
+
+class QLabel;
+
+class AvatarCache : public QObject
+{
+    Q_OBJECT
+
+public:
+    static AvatarCache &instance();
+
+    QPixmap pixmap(qint64 uid, int ver, const QString &name, int size);
+    void applyTo(QLabel *label, qint64 uid, int ver, const QString &name, int size);
+    void invalidate(qint64 uid);
+    void remember(qint64 uid, int ver, const QByteArray &bytes);
+    int knownVer(qint64 uid) const { return m_ver.value(uid, 0); }
+
+signals:
+    void updated(qint64 uid);
+
+private:
+    explicit AvatarCache(QObject *parent = nullptr);
+    QString cacheDir() const;
+    QString filePath(qint64 uid, int ver) const;
+    int diskVer(qint64 uid) const;
+    int resolveVer(qint64 uid, int ver);
+    void ensure(qint64 uid, int ver);
+    void probe(qint64 uid);
+
+    QHash<QString, QPixmap> m_pix;
+    QSet<qint64> m_fetching;
+    QSet<qint64> m_probing;
+    QHash<qint64, int> m_ver;
+};

+ 734 - 0
Client/src/chat/FileCache.cpp

@@ -0,0 +1,734 @@
+#include "FileCache.h"
+#include "AppConfig.h"
+#include "HttpFetch.h"
+#include "ImClient.h"
+#include "LocalStore.h"
+#include "Protocol.h"
+
+#include <QApplication>
+#include <QCoreApplication>
+#include <QDateTime>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QImage>
+#include <QImageReader>
+#include <QIODevice>
+#include <QJsonObject>
+#include <QJsonValue>
+#include <QMap>
+#include <QPointer>
+#include <QRegularExpression>
+
+FileCache &FileCache::instance()
+{
+    static FileCache s;
+    return s;
+}
+
+FileCache::FileCache(QObject *parent)
+    : QObject(parent)
+{
+}
+
+bool FileCache::looksLikeImage(const QString &mime, const QString &name)
+{
+    const QString m = mime.toLower();
+    if (m.startsWith(QStringLiteral("image/")))
+        return true;
+    const QString ext = QFileInfo(name).suffix().toLower();
+    return ext == QLatin1String("jpg") || ext == QLatin1String("jpeg")
+        || ext == QLatin1String("png") || ext == QLatin1String("gif")
+        || ext == QLatin1String("bmp") || ext == QLatin1String("webp");
+}
+
+QString FileCache::formatSize(qint64 bytes)
+{
+    if (bytes < 0)
+        bytes = 0;
+    if (bytes < 1024)
+        return QStringLiteral("%1 B").arg(bytes);
+    if (bytes < 1024 * 1024)
+        return QStringLiteral("%1 KB").arg(bytes / 1024.0, 0, 'f', bytes < 10 * 1024 ? 1 : 0);
+    return QStringLiteral("%1 MB").arg(bytes / (1024.0 * 1024.0), 0, 'f', 2);
+}
+
+QString FileCache::formatSpeed(double bytesPerSec)
+{
+    if (bytesPerSec < 1)
+        return QStringLiteral("0 KB/s");
+    if (bytesPerSec < 1024 * 1024)
+        return QStringLiteral("%1 KB/s").arg(bytesPerSec / 1024.0, 0, 'f', 1);
+    return QStringLiteral("%1 MB/s").arg(bytesPerSec / (1024.0 * 1024.0), 0, 'f', 2);
+}
+
+QString FileCache::statusText(Status st)
+{
+    if (st == Downloading)
+        return QStringLiteral("下载中");
+    if (st == Uploading)
+        return QStringLiteral("发送中");
+    if (st == Received)
+        return QStringLiteral("已接收");
+    return QStringLiteral("未接收");
+}
+
+QString FileCache::cacheDir() const
+{
+    return LocalStore::userSubdir(ImClient::instance().uid(), QStringLiteral("files"));
+}
+
+QString FileCache::safeName(const QString &name)
+{
+    QString base = QFileInfo(name).fileName();
+    if (base.isEmpty())
+        base = QStringLiteral("file");
+    base.replace(QRegularExpression(QStringLiteral("[\\\\/:*?\"<>|]")), QStringLiteral("_"));
+    if (base.size() > 80)
+    {
+        const QString suf = QFileInfo(base).suffix();
+        const QString stem = QFileInfo(base).completeBaseName().left(60);
+        base = suf.isEmpty() ? stem : stem + QLatin1Char('.') + suf;
+    }
+    return base;
+}
+
+QString FileCache::partPath(qint64 fileId) const
+{
+    return cacheDir() + QStringLiteral("/%1.part").arg(fileId);
+}
+
+QString FileCache::destPathFor(qint64 fileId, const QString &name) const
+{
+    const QString n = safeName(name);
+    return cacheDir() + QStringLiteral("/%1_%2").arg(fileId).arg(n);
+}
+
+QString FileCache::findComplete(qint64 fileId) const
+{
+    if (fileId <= 0)
+        return {};
+    const QString known = m_localPath.value(fileId);
+    if (!known.isEmpty() && QFileInfo::exists(known))
+        return known;
+    const QString prefix = QString::number(fileId) + QLatin1Char('_');
+    QDir dir(cacheDir());
+    const QFileInfoList list = dir.entryInfoList(QDir::Files);
+    for (const QFileInfo &fi : list)
+    {
+        const QString n = fi.fileName();
+        if (n.endsWith(QLatin1String(".part")))
+            continue;
+        if (n.startsWith(prefix))
+            return fi.absoluteFilePath();
+    }
+    const QString bin = cacheDir() + QStringLiteral("/%1.bin").arg(fileId);
+    if (QFileInfo::exists(bin))
+        return bin;
+    return {};
+}
+
+QString FileCache::localPath(qint64 fileId) const
+{
+    return findComplete(fileId);
+}
+
+void FileCache::noteMeta(qint64 fileId, const QString &url, const QString &mime, const QString &name,
+                         qint64 size)
+{
+    if (!mime.isEmpty())
+        m_mime.insert(fileId, mime);
+    if (!name.isEmpty())
+        m_name.insert(fileId, name);
+    if (!url.isEmpty())
+        m_url.insert(fileId, url);
+    if (size > 0)
+        m_size.insert(fileId, size);
+}
+
+FileCache::Status FileCache::status(qint64 fileId) const
+{
+    if (fileId <= 0)
+        return NotReceived;
+    if (m_uploads.contains(fileId))
+    {
+        const Job j = m_uploads.value(fileId);
+        if (j.pause && j.pause->load())
+            return NotReceived;
+        return Uploading;
+    }
+    if (m_jobs.contains(fileId))
+        return Downloading;
+    if (m_pendingUpload.contains(fileId))
+        return NotReceived;
+    const QString path = findComplete(fileId);
+    if (path.isEmpty())
+        return NotReceived;
+    const qint64 want = m_size.value(fileId, 0);
+    if (want > 0 && QFileInfo(path).size() < want)
+        return NotReceived;
+    return Received;
+}
+
+qint64 FileCache::expectedSize(qint64 fileId) const
+{
+    if (m_jobs.contains(fileId) && m_jobs.value(fileId).total > 0)
+        return m_jobs.value(fileId).total;
+    return m_size.value(fileId, 0);
+}
+
+void FileCache::bindMeta(qint64 fileId, const QString &url, const QString &mime, const QString &name,
+                         qint64 size)
+{
+    if (fileId <= 0)
+        return;
+    noteMeta(fileId, url, mime, name, size);
+}
+
+void FileCache::progressOf(qint64 fileId, qint64 *received, qint64 *total, double *bytesPerSec) const
+{
+    if (m_uploads.contains(fileId))
+    {
+        const Job j = m_uploads.value(fileId);
+        if (received)
+            *received = j.received;
+        if (total)
+            *total = j.total > 0 ? j.total : m_size.value(fileId, 0);
+        if (bytesPerSec)
+            *bytesPerSec = j.bps;
+        return;
+    }
+    if (m_jobs.contains(fileId))
+    {
+        const Job j = m_jobs.value(fileId);
+        if (received)
+            *received = j.received;
+        if (total)
+            *total = j.total > 0 ? j.total : m_size.value(fileId, 0);
+        if (bytesPerSec)
+            *bytesPerSec = j.bps;
+        return;
+    }
+    if (received)
+        *received = m_pendingUpload.contains(fileId)
+            ? m_uploadSent.value(fileId, 0)
+            : QFileInfo(partPath(fileId)).size();
+    if (total)
+        *total = m_size.value(fileId, 0);
+    if (bytesPerSec)
+        *bytesPerSec = 0;
+}
+
+void FileCache::remember(qint64 fileId, const QByteArray &bytes, const QString &mime, const QString &name)
+{
+    if (fileId <= 0 || bytes.isEmpty())
+        return;
+    m_bytes.insert(fileId, bytes);
+    if (!mime.isEmpty())
+        m_mime.insert(fileId, mime);
+    if (!name.isEmpty())
+        m_name.insert(fileId, name);
+    m_size.insert(fileId, bytes.size());
+    const QString prefix = QString::number(fileId) + QLatin1Char('/');
+    for (auto it = m_thumb.begin(); it != m_thumb.end(); )
+    {
+        if (it.key().startsWith(prefix))
+            it = m_thumb.erase(it);
+        else
+            ++it;
+    }
+    QDir().mkpath(cacheDir());
+    const QString dest = destPathFor(fileId, m_name.value(fileId));
+    QFile::remove(partPath(fileId));
+    QFile f(dest);
+    if (f.open(QIODevice::WriteOnly))
+        f.write(bytes);
+    m_localPath.insert(fileId, dest);
+    emit ready(fileId);
+    emit statusChanged(fileId);
+}
+
+bool FileCache::rememberPath(qint64 fileId, const QString &srcPath, const QString &mime, const QString &name)
+{
+    if (fileId <= 0 || srcPath.isEmpty() || cacheDir().isEmpty())
+        return false;
+    QFileInfo src(srcPath);
+    if (!src.exists() || src.size() <= 0)
+        return false;
+    if (!mime.isEmpty())
+        m_mime.insert(fileId, mime);
+    if (!name.isEmpty())
+        m_name.insert(fileId, name);
+    m_size.insert(fileId, src.size());
+    QDir().mkpath(cacheDir());
+    const QString dest = destPathFor(fileId, m_name.value(fileId, src.fileName()));
+    const QString srcAbs = QDir::cleanPath(src.absoluteFilePath());
+    const QString destAbs = QDir::cleanPath(dest);
+    if (srcAbs.compare(destAbs, Qt::CaseInsensitive) != 0)
+    {
+        QFile::remove(dest);
+        if (!QFile::copy(srcAbs, dest))
+            return false;
+    }
+    m_localPath.insert(fileId, dest);
+    m_pendingUpload.insert(fileId);
+    emit statusChanged(fileId);
+    return true;
+}
+
+QMap<QString, QString> FileCache::headerMap(const QJsonObject &headers)
+{
+    QMap<QString, QString> out;
+    for (auto it = headers.begin(); it != headers.end(); ++it)
+    {
+        const QJsonValue v = it.value();
+        QString s;
+        if (v.isString())
+            s = v.toString();
+        else if (v.isDouble())
+            s = QString::number(v.toVariant().toLongLong());
+        else if (v.isBool())
+            s = v.toBool() ? QStringLiteral("true") : QStringLiteral("false");
+        else
+            s = v.toVariant().toString();
+        if (!it.key().isEmpty() && !s.isEmpty())
+            out.insert(it.key(), s);
+    }
+    return out;
+}
+
+bool FileCache::needsUpload(qint64 fileId) const
+{
+    return m_pendingUpload.contains(fileId) || m_uploads.contains(fileId);
+}
+
+void FileCache::startUpload(qint64 fileId, const QJsonObject &token)
+{
+    if (fileId <= 0)
+        return;
+    if (!token.isEmpty())
+        m_uploadToken.insert(fileId, token);
+    if (m_uploads.contains(fileId))
+    {
+        if (m_uploads[fileId].pause)
+            m_uploads[fileId].pause->store(false);
+        emit statusChanged(fileId);
+        return;
+    }
+    if (!m_pendingUpload.contains(fileId))
+    {
+        emit uploaded(fileId);
+        emit statusChanged(fileId);
+        return;
+    }
+
+    const QJsonObject tok = m_uploadToken.value(fileId);
+    const QString url = tok.value(QStringLiteral("upload_url")).toString();
+    const QString path = findComplete(fileId);
+    if (url.isEmpty() || path.isEmpty())
+    {
+        emit failed(fileId, QStringLiteral("没有上传地址"));
+        return;
+    }
+
+    Job job;
+    job.total = QFileInfo(path).size();
+    job.cancel = std::make_shared<std::atomic<bool>>(false);
+    job.pause = std::make_shared<std::atomic<bool>>(false);
+    job.lastMs = QDateTime::currentMSecsSinceEpoch();
+    m_uploads.insert(fileId, job);
+    emit statusChanged(fileId);
+    emit progress(fileId, 0, job.total, 0);
+
+    QPointer<FileCache> self(this);
+    HttpFetch::instance().put(
+        url, path, headerMap(tok.value(QStringLiteral("headers")).toObject()), job.cancel,
+        [self, fileId](qint64 rec, qint64 tot) {
+            if (!self || !self->m_uploads.contains(fileId))
+                return;
+            Job &j = self->m_uploads[fileId];
+            j.received = rec;
+            self->m_uploadSent.insert(fileId, rec);
+            if (tot > 0)
+                j.total = tot;
+            const qint64 now = QDateTime::currentMSecsSinceEpoch();
+            const qint64 dt = now - j.lastMs;
+            if (dt > 0)
+            {
+                const double inst = (rec - j.lastBytes) * 1000.0 / static_cast<double>(dt);
+                j.bps = j.bps > 0 ? (j.bps * 0.6 + inst * 0.4) : inst;
+                j.lastBytes = rec;
+                j.lastMs = now;
+            }
+            emit self->progress(fileId, rec, j.total, j.bps);
+        },
+        [self, fileId](bool ok, const QString &error) {
+            if (!self)
+                return;
+            self->finishUpload(fileId, ok, error);
+        },
+        job.pause);
+}
+
+void FileCache::cancelUpload(qint64 fileId)
+{
+    if (!m_uploads.contains(fileId))
+        return;
+    if (m_uploads[fileId].pause)
+        m_uploads[fileId].pause->store(true);
+    m_uploads[fileId].bps = 0;
+    emit statusChanged(fileId);
+    emit progress(fileId, m_uploads[fileId].received, m_uploads[fileId].total, 0);
+}
+
+void FileCache::finishUpload(qint64 fileId, bool ok, const QString &error)
+{
+    const Job job = m_uploads.take(fileId);
+    if (!ok)
+    {
+        m_uploadSent.insert(fileId, job.received);
+        emit statusChanged(fileId);
+        emit progress(fileId, job.received, job.total > 0 ? job.total : m_size.value(fileId, 0), 0);
+        if (error != QLatin1String("cancelled"))
+            emit failed(fileId, error.isEmpty() ? QStringLiteral("上传失败") : error);
+        return;
+    }
+    m_pendingUpload.remove(fileId);
+    m_uploadToken.remove(fileId);
+    m_uploadSent.remove(fileId);
+    emit uploaded(fileId);
+    emit ready(fileId);
+    emit statusChanged(fileId);
+}
+
+bool FileCache::loadDisk(qint64 fileId)
+{
+    if (m_bytes.contains(fileId))
+        return true;
+    const QString path = findComplete(fileId);
+    if (path.isEmpty())
+        return false;
+    QFile f(path);
+    if (!f.open(QIODevice::ReadOnly))
+        return false;
+    const QByteArray data = f.readAll();
+    if (data.isEmpty())
+        return false;
+    m_bytes.insert(fileId, data);
+    m_localPath.insert(fileId, path);
+    return true;
+}
+
+QByteArray FileCache::bytes(qint64 fileId)
+{
+    if (fileId <= 0)
+        return {};
+    if (m_bytes.contains(fileId))
+        return m_bytes.value(fileId);
+    loadDisk(fileId);
+    return m_bytes.value(fileId);
+}
+
+QString FileCache::mime(qint64 fileId) const
+{
+    return m_mime.value(fileId);
+}
+
+QString FileCache::name(qint64 fileId) const
+{
+    return m_name.value(fileId);
+}
+
+QPixmap FileCache::thumbnail(qint64 fileId, int maxEdge)
+{
+    const QString key = QStringLiteral("%1/%2").arg(fileId).arg(maxEdge);
+    if (m_thumb.contains(key))
+        return m_thumb.value(key);
+    QImage img;
+    const QString path = findComplete(fileId);
+    if (!path.isEmpty())
+    {
+        QImageReader reader(path);
+        reader.setAutoTransform(true);
+        QSize sz = reader.size();
+        if (sz.isValid())
+        {
+            const int cap = qMax(32, maxEdge);
+            sz.scale(QSize(cap, cap), Qt::KeepAspectRatio);
+            reader.setScaledSize(sz);
+        }
+        img = reader.read();
+    }
+    else
+    {
+        const QByteArray data = bytes(fileId);
+        if (data.isEmpty() || !img.loadFromData(data))
+            return {};
+    }
+    if (img.isNull())
+        return {};
+    const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+    const int cap = qMax(32, maxEdge);
+    QSize box(cap, cap);
+    QSize scaled = img.size();
+    scaled.scale(box, Qt::KeepAspectRatio);
+    const QImage out = img.scaled(qRound(scaled.width() * dpr), qRound(scaled.height() * dpr),
+                                  Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
+    QPixmap pm = QPixmap::fromImage(out);
+    pm.setDevicePixelRatio(dpr);
+    m_thumb.insert(key, pm);
+    return pm;
+}
+
+void FileCache::ensure(qint64 fileId, const QString &url, const QString &mime, const QString &name)
+{
+    if (fileId <= 0 || m_fetching.contains(fileId))
+        return;
+    noteMeta(fileId, url, mime, name, 0);
+    if (loadDisk(fileId))
+    {
+        emit ready(fileId);
+        emit statusChanged(fileId);
+        return;
+    }
+    m_fetching.insert(fileId);
+    QPointer<FileCache> self(this);
+
+    auto takeHttp = [self, fileId](const QString &path) {
+        if (!self)
+            return;
+        const QString http = AppConfig::instance().resourceUrl(path);
+        if (http.isEmpty())
+        {
+            self->m_fetching.remove(fileId);
+            return;
+        }
+        HttpFetch::instance().get(http, [self, fileId](bool ok, const QByteArray &data, const QString &) {
+            if (!self)
+                return;
+            self->m_fetching.remove(fileId);
+            if (!ok || data.isEmpty())
+                return;
+            self->remember(fileId, data, self->m_mime.value(fileId), self->m_name.value(fileId));
+        });
+    };
+
+    const QString known = url.isEmpty() ? m_url.value(fileId) : url;
+    const QString http = AppConfig::instance().resourceUrl(known);
+    if (!http.isEmpty())
+    {
+        takeHttp(known);
+        return;
+    }
+
+    ImClient::instance().getFile(fileId, [self, fileId, takeHttp](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            self->m_fetching.remove(fileId);
+            return;
+        }
+        const QString gotMime = reply.value(QStringLiteral("mime")).toString();
+        const QString gotName = reply.value(QStringLiteral("name")).toString();
+        if (!gotMime.isEmpty())
+            self->m_mime.insert(fileId, gotMime);
+        if (!gotName.isEmpty())
+            self->m_name.insert(fileId, gotName);
+        const QString gotUrl = reply.value(QStringLiteral("url")).toString();
+        if (!gotUrl.isEmpty())
+        {
+            self->m_url.insert(fileId, gotUrl);
+            takeHttp(gotUrl);
+            return;
+        }
+        const QByteArray data = QByteArray::fromBase64(
+            reply.value(QStringLiteral("data")).toString().toLatin1());
+        self->m_fetching.remove(fileId);
+        if (data.isEmpty())
+            return;
+        self->remember(fileId, data, self->m_mime.value(fileId), self->m_name.value(fileId));
+    });
+}
+
+void FileCache::beginHttp(qint64 fileId, const QString &path)
+{
+    const QString http = AppConfig::instance().resourceUrl(path);
+    if (http.isEmpty())
+    {
+        finishDownload(fileId, false, QStringLiteral("没有下载地址"));
+        return;
+    }
+    QDir().mkpath(cacheDir());
+    const QString part = partPath(fileId);
+    qint64 resume = QFileInfo(part).size();
+    Job &job = m_jobs[fileId];
+    if (job.total > 0 && resume > job.total)
+    {
+        QFile::remove(part);
+        resume = 0;
+    }
+    job.received = resume;
+    job.lastBytes = resume;
+    job.lastMs = QDateTime::currentMSecsSinceEpoch();
+    emit progress(fileId, job.received, job.total, 0);
+
+    QPointer<FileCache> self(this);
+    HttpFetch::instance().download(
+        http, part, resume, job.total, job.cancel,
+        [self, fileId](qint64 rec, qint64 tot) {
+            if (!self || !self->m_jobs.contains(fileId))
+                return;
+            Job &j = self->m_jobs[fileId];
+            j.received = rec;
+            if (tot > 0)
+                j.total = tot;
+            const qint64 now = QDateTime::currentMSecsSinceEpoch();
+            const qint64 dt = now - j.lastMs;
+            if (dt > 0)
+            {
+                const double inst = (rec - j.lastBytes) * 1000.0 / static_cast<double>(dt);
+                j.bps = j.bps > 0 ? (j.bps * 0.6 + inst * 0.4) : inst;
+                j.lastBytes = rec;
+                j.lastMs = now;
+            }
+            emit self->progress(fileId, rec, j.total, j.bps);
+        },
+        [self, fileId](bool ok, const QString &error) {
+            if (!self)
+                return;
+            self->finishDownload(fileId, ok, error);
+        });
+}
+
+void FileCache::startDownload(qint64 fileId, const QString &url, const QString &mime, const QString &name,
+                              qint64 size)
+{
+    if (fileId <= 0)
+        return;
+    noteMeta(fileId, url, mime, name, size);
+    if (status(fileId) == Received)
+    {
+        emit ready(fileId);
+        emit statusChanged(fileId);
+        return;
+    }
+    if (m_jobs.contains(fileId))
+        return;
+
+    Job job;
+    job.total = size > 0 ? size : m_size.value(fileId, 0);
+    job.cancel = std::make_shared<std::atomic<bool>>(false);
+    job.lastMs = QDateTime::currentMSecsSinceEpoch();
+    const qint64 partSz = QFileInfo(partPath(fileId)).size();
+    job.received = partSz;
+    job.lastBytes = partSz;
+    m_jobs.insert(fileId, job);
+    emit statusChanged(fileId);
+
+    QPointer<FileCache> self(this);
+    const QString known = url.isEmpty() ? m_url.value(fileId) : url;
+    const QString http = AppConfig::instance().resourceUrl(known);
+    if (!http.isEmpty())
+    {
+        beginHttp(fileId, known);
+        return;
+    }
+
+    ImClient::instance().getFile(fileId, [self, fileId](const QJsonObject &reply) {
+        if (!self || !self->m_jobs.contains(fileId))
+            return;
+        if (!ImClient::ok(reply))
+        {
+            self->finishDownload(fileId, false, ImClient::messageOf(reply));
+            return;
+        }
+        const QString gotMime = reply.value(QStringLiteral("mime")).toString();
+        const QString gotName = reply.value(QStringLiteral("name")).toString();
+        const qint64 gotSize = Proto::jsonI64(reply, QStringLiteral("size"));
+        if (!gotMime.isEmpty())
+            self->m_mime.insert(fileId, gotMime);
+        if (!gotName.isEmpty())
+            self->m_name.insert(fileId, gotName);
+        if (gotSize > 0)
+        {
+            self->m_size.insert(fileId, gotSize);
+            self->m_jobs[fileId].total = gotSize;
+        }
+        const QString gotUrl = reply.value(QStringLiteral("url")).toString();
+        if (!gotUrl.isEmpty())
+        {
+            self->m_url.insert(fileId, gotUrl);
+            self->beginHttp(fileId, gotUrl);
+            return;
+        }
+        const QByteArray data = QByteArray::fromBase64(
+            reply.value(QStringLiteral("data")).toString().toLatin1());
+        if (data.isEmpty())
+        {
+            self->finishDownload(fileId, false, QStringLiteral("空文件"));
+            return;
+        }
+        self->m_jobs.remove(fileId);
+        self->remember(fileId, data, self->m_mime.value(fileId), self->m_name.value(fileId));
+    });
+}
+
+void FileCache::cancelDownload(qint64 fileId)
+{
+    if (!m_jobs.contains(fileId))
+        return;
+    if (m_jobs[fileId].cancel)
+        m_jobs[fileId].cancel->store(true);
+}
+
+void FileCache::finishDownload(qint64 fileId, bool ok, const QString &error)
+{
+    const Job job = m_jobs.take(fileId);
+    if (!ok)
+    {
+        const qint64 rec = QFileInfo(partPath(fileId)).size();
+        const qint64 tot = job.total > 0 ? job.total : m_size.value(fileId, 0);
+        emit statusChanged(fileId);
+        emit progress(fileId, rec, tot, 0);
+        if (error != QLatin1String("cancelled"))
+            emit failed(fileId, error.isEmpty() ? QStringLiteral("下载失败") : error);
+        return;
+    }
+
+    const QString part = partPath(fileId);
+    const QString dest = destPathFor(fileId, m_name.value(fileId));
+    const qint64 want = job.total > 0 ? job.total : m_size.value(fileId, 0);
+    const qint64 got = QFileInfo(part).exists() ? QFileInfo(part).size() : QFileInfo(dest).size();
+    if (want > 0 && got > 0 && got < want)
+    {
+        emit statusChanged(fileId);
+        emit failed(fileId, QStringLiteral("文件不完整"));
+        return;
+    }
+    QDir().mkpath(cacheDir());
+    if (QFileInfo::exists(part))
+    {
+        QFile::remove(dest);
+        if (!QFile::rename(part, dest))
+        {
+            if (!QFile::copy(part, dest))
+            {
+                emit statusChanged(fileId);
+                emit failed(fileId, QStringLiteral("保存失败"));
+                return;
+            }
+            QFile::remove(part);
+        }
+    }
+    if (!QFileInfo::exists(dest))
+    {
+        emit statusChanged(fileId);
+        emit failed(fileId, QStringLiteral("保存失败"));
+        return;
+    }
+    m_localPath.insert(fileId, dest);
+    m_size.insert(fileId, QFileInfo(dest).size());
+    emit ready(fileId);
+    emit statusChanged(fileId);
+}

+ 104 - 0
Client/src/chat/FileCache.h

@@ -0,0 +1,104 @@
+#pragma once
+
+#include <QByteArray>
+#include <QHash>
+#include <QJsonObject>
+#include <QMap>
+#include <QObject>
+#include <QPixmap>
+#include <QSet>
+#include <QString>
+#include <atomic>
+#include <memory>
+
+class FileCache : public QObject
+{
+    Q_OBJECT
+
+public:
+    enum Status
+    {
+        NotReceived = 0,
+        Downloading = 1,
+        Received = 2,
+        Uploading = 3
+    };
+
+    static FileCache &instance();
+
+    static bool looksLikeImage(const QString &mime, const QString &name = QString());
+    static QString formatSize(qint64 bytes);
+    static QString formatSpeed(double bytesPerSec);
+    static QString statusText(Status st);
+
+    void remember(qint64 fileId, const QByteArray &bytes, const QString &mime = QString(),
+                  const QString &name = QString());
+    bool rememberPath(qint64 fileId, const QString &srcPath, const QString &mime = QString(),
+                      const QString &name = QString());
+    QByteArray bytes(qint64 fileId);
+    QString mime(qint64 fileId) const;
+    QString name(qint64 fileId) const;
+    QString localPath(qint64 fileId) const;
+    QPixmap thumbnail(qint64 fileId, int maxEdge);
+    void ensure(qint64 fileId, const QString &url = QString(), const QString &mime = QString(),
+                const QString &name = QString());
+
+    Status status(qint64 fileId) const;
+    qint64 expectedSize(qint64 fileId) const;
+    void bindMeta(qint64 fileId, const QString &url, const QString &mime, const QString &name,
+                  qint64 size);
+    void progressOf(qint64 fileId, qint64 *received, qint64 *total, double *bytesPerSec) const;
+    void startDownload(qint64 fileId, const QString &url, const QString &mime, const QString &name,
+                       qint64 size);
+    void cancelDownload(qint64 fileId);
+    bool needsUpload(qint64 fileId) const;
+    void startUpload(qint64 fileId, const QJsonObject &token = QJsonObject());
+    void cancelUpload(qint64 fileId);
+
+signals:
+    void ready(qint64 fileId);
+    void uploaded(qint64 fileId);
+    void statusChanged(qint64 fileId);
+    void progress(qint64 fileId, qint64 received, qint64 total, double bytesPerSec);
+    void failed(qint64 fileId, const QString &error);
+
+private:
+    struct Job
+    {
+        qint64 received = 0;
+        qint64 total = 0;
+        double bps = 0;
+        qint64 lastBytes = 0;
+        qint64 lastMs = 0;
+        std::shared_ptr<std::atomic<bool>> cancel;
+        std::shared_ptr<std::atomic<bool>> pause;
+    };
+
+    explicit FileCache(QObject *parent = nullptr);
+    QString cacheDir() const;
+    QString partPath(qint64 fileId) const;
+    QString destPathFor(qint64 fileId, const QString &name) const;
+    QString findComplete(qint64 fileId) const;
+    static QString safeName(const QString &name);
+    bool loadDisk(qint64 fileId);
+    void finishDownload(qint64 fileId, bool ok, const QString &error);
+    void beginHttp(qint64 fileId, const QString &path);
+    void noteMeta(qint64 fileId, const QString &url, const QString &mime, const QString &name,
+                  qint64 size);
+    void finishUpload(qint64 fileId, bool ok, const QString &error);
+    static QMap<QString, QString> headerMap(const QJsonObject &headers);
+
+    QHash<qint64, QByteArray> m_bytes;
+    QHash<qint64, QString> m_mime;
+    QHash<qint64, QString> m_name;
+    QHash<qint64, QString> m_url;
+    QHash<qint64, QString> m_localPath;
+    QHash<qint64, qint64> m_size;
+    QHash<QString, QPixmap> m_thumb;
+    QSet<qint64> m_fetching;
+    QHash<qint64, Job> m_jobs;
+    QHash<qint64, Job> m_uploads;
+    QHash<qint64, qint64> m_uploadSent;
+    QSet<qint64> m_pendingUpload;
+    QHash<qint64, QJsonObject> m_uploadToken;
+};

+ 111 - 0
Client/src/chat/FileTypeIcon.cpp

@@ -0,0 +1,111 @@
+#include "FileTypeIcon.h"
+
+#include <QApplication>
+#include <QFileInfo>
+#include <QFont>
+#include <QHash>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPen>
+
+QString FileTypeIcon::extension(const QString &name)
+{
+    return QFileInfo(name).suffix().toLower();
+}
+
+namespace
+{
+    QColor colorFor(const QString &ext)
+    {
+        if (ext == QLatin1String("pdf"))
+            return QColor(QStringLiteral("#E53935"));
+        if (ext == QLatin1String("doc") || ext == QLatin1String("docx") || ext == QLatin1String("rtf"))
+            return QColor(QStringLiteral("#1E88E5"));
+        if (ext == QLatin1String("xls") || ext == QLatin1String("xlsx") || ext == QLatin1String("csv"))
+            return QColor(QStringLiteral("#43A047"));
+        if (ext == QLatin1String("ppt") || ext == QLatin1String("pptx"))
+            return QColor(QStringLiteral("#FB8C00"));
+        if (ext == QLatin1String("zip") || ext == QLatin1String("rar") || ext == QLatin1String("7z")
+            || ext == QLatin1String("gz") || ext == QLatin1String("tar"))
+            return QColor(QStringLiteral("#8E24AA"));
+        if (ext == QLatin1String("mp4") || ext == QLatin1String("mov") || ext == QLatin1String("mkv")
+            || ext == QLatin1String("avi") || ext == QLatin1String("wmv"))
+            return QColor(QStringLiteral("#3949AB"));
+        if (ext == QLatin1String("mp3") || ext == QLatin1String("wav") || ext == QLatin1String("flac")
+            || ext == QLatin1String("aac") || ext == QLatin1String("m4a"))
+            return QColor(QStringLiteral("#00897B"));
+        if (ext == QLatin1String("exe") || ext == QLatin1String("msi") || ext == QLatin1String("apk")
+            || ext == QLatin1String("dll"))
+            return QColor(QStringLiteral("#546E7A"));
+        if (ext == QLatin1String("txt") || ext == QLatin1String("md") || ext == QLatin1String("log")
+            || ext == QLatin1String("json") || ext == QLatin1String("xml"))
+            return QColor(QStringLiteral("#78909C"));
+        return QColor(QStringLiteral("#607D8B"));
+    }
+}
+
+QPixmap FileTypeIcon::pixmap(const QString &name, int size)
+{
+    const int edge = qMax(24, size);
+    QString ext = extension(name).toUpper();
+    if (ext.isEmpty())
+        ext = QStringLiteral("FILE");
+    if (ext.size() > 4)
+        ext = ext.left(4);
+
+    static QHash<QString, QPixmap> cache;
+    const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+    const QString key = QStringLiteral("%1/%2/%3").arg(ext).arg(edge).arg(dpr, 0, 'f', 2);
+    if (cache.contains(key))
+        return cache.value(key);
+
+    const int px = qRound(edge * dpr);
+    QPixmap pm(px, px);
+    pm.fill(Qt::transparent);
+    pm.setDevicePixelRatio(dpr);
+
+    QPainter p(&pm);
+    p.setRenderHint(QPainter::Antialiasing, true);
+    p.scale(dpr, dpr);
+
+    const QColor accent = colorFor(ext.toLower());
+    const QRectF page(2.5, 1.5, edge - 5.0, edge - 3.0);
+    const qreal fold = qMax(7.0, edge * 0.22);
+
+    QPainterPath body;
+    body.moveTo(page.left(), page.top() + 3);
+    body.quadTo(page.left(), page.top(), page.left() + 3, page.top());
+    body.lineTo(page.right() - fold, page.top());
+    body.lineTo(page.right(), page.top() + fold);
+    body.lineTo(page.right(), page.bottom() - 3);
+    body.quadTo(page.right(), page.bottom(), page.right() - 3, page.bottom());
+    body.lineTo(page.left() + 3, page.bottom());
+    body.quadTo(page.left(), page.bottom(), page.left(), page.bottom() - 3);
+    body.closeSubpath();
+    p.fillPath(body, QColor(QStringLiteral("#FAFBFC")));
+    p.setPen(QPen(QColor(0, 0, 0, 28), 1.0));
+    p.drawPath(body);
+
+    QPainterPath dogear;
+    dogear.moveTo(page.right() - fold, page.top());
+    dogear.lineTo(page.right() - fold, page.top() + fold - 1);
+    dogear.lineTo(page.right(), page.top() + fold);
+    dogear.closeSubpath();
+    p.fillPath(dogear, accent.lighter(130));
+
+    const QRectF band(page.left() + 1, page.bottom() - edge * 0.38, page.width() - 2, edge * 0.32);
+    p.setPen(Qt::NoPen);
+    p.setBrush(accent);
+    p.drawRoundedRect(band, 2.5, 2.5);
+
+    QFont f = p.font();
+    f.setBold(true);
+    f.setPixelSize(qMax(8, qRound(edge * (ext.size() > 3 ? 0.22 : 0.26))));
+    p.setFont(f);
+    p.setPen(Qt::white);
+    p.drawText(band, Qt::AlignCenter, ext);
+    p.end();
+
+    cache.insert(key, pm);
+    return pm;
+}

+ 11 - 0
Client/src/chat/FileTypeIcon.h

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <QPixmap>
+#include <QString>
+
+class FileTypeIcon
+{
+public:
+    static QString extension(const QString &name);
+    static QPixmap pixmap(const QString &name, int size = 40);
+};

+ 184 - 0
Client/src/chat/StickerCache.cpp

@@ -0,0 +1,184 @@
+#include "StickerCache.h"
+#include "AppConfig.h"
+#include "HttpFetch.h"
+#include "ImClient.h"
+#include "LocalStore.h"
+#include "Protocol.h"
+
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QPointer>
+
+StickerCache &StickerCache::instance()
+{
+    static StickerCache s;
+    return s;
+}
+
+StickerCache::StickerCache(QObject *parent)
+    : QObject(parent)
+{
+}
+
+QString StickerCache::cacheDir() const
+{
+    return LocalStore::userSubdir(ImClient::instance().uid(), QStringLiteral("stickers"));
+}
+
+QString StickerCache::filePath(qint64 packId, const QString &code) const
+{
+    return cacheDir() + QStringLiteral("/%1/%2.png").arg(packId).arg(code);
+}
+
+void StickerCache::sync()
+{
+    if (m_syncing)
+        return;
+    m_syncing = true;
+    QPointer<StickerCache> self(this);
+    ImClient::instance().stickerPackList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            self->m_syncing = false;
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        self->m_packs.clear();
+        if (list.isEmpty())
+        {
+            self->m_syncing = false;
+            emit self->ready();
+            return;
+        }
+        for (const QJsonValue &v : list)
+        {
+            const QJsonObject o = v.toObject();
+            Pack p;
+            p.id = Proto::jsonI64(o, QStringLiteral("id"));
+            p.name = o.value(QStringLiteral("name")).toString();
+            p.version = o.value(QStringLiteral("version")).toInt(1);
+            self->m_packs.push_back(p);
+        }
+        for (int i = 0; i < self->m_packs.size(); ++i)
+        {
+            const qint64 packId = self->m_packs[i].id;
+            ImClient::instance().stickerPack(packId, [self, packId](const QJsonObject &packReply) {
+                if (!self)
+                    return;
+                if (ImClient::ok(packReply))
+                {
+                    for (int k = 0; k < self->m_packs.size(); ++k)
+                    {
+                        if (self->m_packs[k].id != packId)
+                            continue;
+                        self->m_packs[k].name = packReply.value(QStringLiteral("name")).toString();
+                        self->m_packs[k].version = packReply.value(QStringLiteral("version")).toInt(1);
+                        self->m_packs[k].stickers.clear();
+                        const QJsonArray stickers = packReply.value(QStringLiteral("stickers")).toArray();
+                        for (const QJsonValue &sv : stickers)
+                        {
+                            const QJsonObject so = sv.toObject();
+                            Sticker s;
+                            s.id = Proto::jsonI64(so, QStringLiteral("id"));
+                            s.code = so.value(QStringLiteral("code")).toString();
+                            s.name = so.value(QStringLiteral("name")).toString();
+                            s.sha256 = so.value(QStringLiteral("sha256")).toString();
+                            s.mime = so.value(QStringLiteral("mime")).toString();
+                            s.url = so.value(QStringLiteral("url")).toString();
+                            self->m_packs[k].stickers.push_back(s);
+                        }
+                        self->fetchMissing(self->m_packs[k]);
+                        break;
+                    }
+                }
+                bool all = true;
+                for (const Pack &p : self->m_packs)
+                {
+                    if (p.stickers.isEmpty() && p.id > 0)
+                        all = false;
+                }
+                if (all)
+                {
+                    self->m_syncing = false;
+                    emit self->ready();
+                }
+            });
+        }
+    });
+}
+
+void StickerCache::fetchMissing(const Pack &pack)
+{
+    if (cacheDir().isEmpty())
+        return;
+    QDir().mkpath(cacheDir() + QStringLiteral("/%1").arg(pack.id));
+    for (const Sticker &s : pack.stickers)
+    {
+        const QString path = filePath(pack.id, s.code);
+        if (QFile::exists(path))
+            continue;
+        QPointer<StickerCache> self(this);
+        const qint64 packId = pack.id;
+        const QString code = s.code;
+        auto saveBytes = [self, packId, code](const QByteArray &bytes) {
+            if (!self || bytes.isEmpty())
+                return;
+            self->saveSticker(packId, code, bytes);
+            emit self->stickerReady(packId, code);
+        };
+        const QString http = AppConfig::instance().resourceUrl(s.url);
+        if (!http.isEmpty())
+        {
+            HttpFetch::instance().get(http, [saveBytes](bool ok, const QByteArray &bytes, const QString &) {
+                if (ok)
+                    saveBytes(bytes);
+            });
+            continue;
+        }
+        ImClient::instance().getSticker(packId, code, [self, packId, code, saveBytes](const QJsonObject &reply) {
+            if (!self || !ImClient::ok(reply))
+                return;
+            const QString url = reply.value(QStringLiteral("url")).toString();
+            const QString httpUrl = AppConfig::instance().resourceUrl(url);
+            if (!httpUrl.isEmpty())
+            {
+                HttpFetch::instance().get(httpUrl, [saveBytes](bool ok, const QByteArray &bytes, const QString &) {
+                    if (ok)
+                        saveBytes(bytes);
+                });
+                return;
+            }
+            saveBytes(QByteArray::fromBase64(reply.value(QStringLiteral("data")).toString().toLatin1()));
+        });
+    }
+}
+
+void StickerCache::saveSticker(qint64 packId, const QString &code, const QByteArray &bytes)
+{
+    const QString path = filePath(packId, code);
+    QDir().mkpath(QFileInfo(path).absolutePath());
+    QFile f(path);
+    if (!f.open(QIODevice::WriteOnly))
+        return;
+    f.write(bytes);
+}
+
+QPixmap StickerCache::pixmap(qint64 packId, const QString &code, int size)
+{
+    const QString key = QStringLiteral("%1/%2/%3").arg(packId).arg(code).arg(size);
+    if (m_pix.contains(key))
+        return m_pix.value(key);
+    QPixmap pm(filePath(packId, code));
+    if (pm.isNull())
+        return QPixmap();
+    const QPixmap scaled = pm.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+    m_pix.insert(key, scaled);
+    return scaled;
+}

+ 53 - 0
Client/src/chat/StickerCache.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <QHash>
+#include <QJsonArray>
+#include <QObject>
+#include <QPixmap>
+#include <QString>
+#include <QVector>
+
+class StickerCache : public QObject
+{
+    Q_OBJECT
+
+public:
+    struct Sticker
+    {
+        qint64 id = 0;
+        QString code;
+        QString name;
+        QString sha256;
+        QString mime;
+        QString url;
+    };
+
+    struct Pack
+    {
+        qint64 id = 0;
+        QString name;
+        int version = 1;
+        QVector<Sticker> stickers;
+    };
+
+    static StickerCache &instance();
+
+    void sync();
+    QVector<Pack> packs() const { return m_packs; }
+    QPixmap pixmap(qint64 packId, const QString &code, int size = 64);
+
+signals:
+    void ready();
+    void stickerReady(qint64 packId, const QString &code);
+
+private:
+    explicit StickerCache(QObject *parent = nullptr);
+    QString cacheDir() const;
+    QString filePath(qint64 packId, const QString &code) const;
+    void fetchMissing(const Pack &pack);
+    void saveSticker(qint64 packId, const QString &code, const QByteArray &bytes);
+
+    QVector<Pack> m_packs;
+    QHash<QString, QPixmap> m_pix;
+    bool m_syncing = false;
+};

+ 132 - 0
Client/src/chat/StickerPanel.cpp

@@ -0,0 +1,132 @@
+#include "StickerPanel.h"
+#include "StickerCache.h"
+#include "AppTheme.h"
+
+#include <QFrame>
+#include <QGridLayout>
+#include <QIcon>
+#include <QLabel>
+#include <QPushButton>
+#include <QPushButton>
+#include <QScrollArea>
+#include <QShowEvent>
+#include <QStackedWidget>
+#include <QTabBar>
+#include <QVBoxLayout>
+
+StickerPanel::StickerPanel(QWidget *parent)
+    : QWidget(parent)
+{
+    setFixedSize(360, 260);
+    setStyleSheet(QStringLiteral(
+        "StickerPanel { background:#FFFFFF; border:1px solid #E5E5E5; }"));
+
+    auto *lay = new QVBoxLayout(this);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
+
+    m_tabs = new QTabBar(this);
+    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::tab:selected { color:#07C160; background:#FFFFFF; }"));
+    m_pages = new QStackedWidget(this);
+    lay->addWidget(m_tabs);
+    lay->addWidget(m_pages, 1);
+
+    connect(m_tabs, &QTabBar::currentChanged, m_pages, &QStackedWidget::setCurrentIndex);
+    connect(&StickerCache::instance(), &StickerCache::ready, this, &StickerPanel::rebuild);
+    connect(&StickerCache::instance(), &StickerCache::stickerReady, this, [this](qint64, const QString &) {
+        rebuild();
+    });
+}
+
+void StickerPanel::showEvent(QShowEvent *event)
+{
+    QWidget::showEvent(event);
+    reload();
+}
+
+void StickerPanel::reload()
+{
+    if (StickerCache::instance().packs().isEmpty())
+        StickerCache::instance().sync();
+    else
+        rebuild();
+}
+
+void StickerPanel::rebuild()
+{
+    const int keep = m_tabs->currentIndex();
+    while (m_tabs->count() > 0)
+        m_tabs->removeTab(0);
+    while (m_pages->count() > 0)
+    {
+        QWidget *w = m_pages->widget(0);
+        m_pages->removeWidget(w);
+        w->deleteLater();
+    }
+    const auto packs = StickerCache::instance().packs();
+    for (int i = 0; i < packs.size(); ++i)
+    {
+        m_tabs->addTab(packs[i].name);
+        m_pages->addWidget(buildPackPage(i));
+    }
+    if (packs.isEmpty())
+    {
+        auto *empty = new QLabel(QStringLiteral("表情包加载中…"), m_pages);
+        empty->setAlignment(Qt::AlignCenter);
+        empty->setStyleSheet(QStringLiteral("color:#B0B0B0;"));
+        m_pages->addWidget(empty);
+    }
+    if (keep >= 0 && keep < m_tabs->count())
+        m_tabs->setCurrentIndex(keep);
+}
+
+QWidget *StickerPanel::buildPackPage(int packIndex)
+{
+    const auto packs = StickerCache::instance().packs();
+    auto *page = new QWidget(m_pages);
+    auto *outer = new QVBoxLayout(page);
+    outer->setContentsMargins(0, 0, 0, 0);
+    auto *scroll = new QScrollArea(page);
+    scroll->setWidgetResizable(true);
+    scroll->setFrameShape(QFrame::NoFrame);
+    auto *host = new QWidget(scroll);
+    auto *grid = new QGridLayout(host);
+    grid->setContentsMargins(10, 8, 10, 8);
+    grid->setSpacing(6);
+    if (packIndex >= 0 && packIndex < packs.size())
+    {
+        const auto &pack = packs[packIndex];
+        int col = 0;
+        int row = 0;
+        for (const auto &s : pack.stickers)
+        {
+            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);
+            if (!pm.isNull())
+                btn->setIcon(QIcon(pm));
+            btn->setIconSize(QSize(48, 48));
+            const qint64 packId = pack.id;
+            const QString code = s.code;
+            connect(btn, &QPushButton::clicked, this, [this, packId, code]() {
+                emit stickerPicked(packId, code);
+            });
+            grid->addWidget(btn, row, col);
+            if (++col >= 6)
+            {
+                col = 0;
+                ++row;
+            }
+        }
+    }
+    scroll->setWidget(host);
+    outer->addWidget(scroll);
+    return page;
+}

+ 28 - 0
Client/src/chat/StickerPanel.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <QWidget>
+
+class QTabBar;
+class QStackedWidget;
+
+class StickerPanel : public QWidget
+{
+    Q_OBJECT
+
+public:
+    explicit StickerPanel(QWidget *parent = nullptr);
+    void reload();
+
+signals:
+    void stickerPicked(qint64 packId, const QString &code);
+
+protected:
+    void showEvent(QShowEvent *event) override;
+
+private:
+    void rebuild();
+    QWidget *buildPackPage(int packIndex);
+
+    QTabBar *m_tabs = nullptr;
+    QStackedWidget *m_pages = nullptr;
+};

+ 284 - 0
Client/src/common/AppConfig.cpp

@@ -0,0 +1,284 @@
+#include "AppConfig.h"
+#include "Logger.h"
+
+#include <QCoreApplication>
+#include <QDir>
+#include <QFileInfo>
+#include <QJsonValue>
+#include <QList>
+#include <QSettings>
+#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)
+{
+    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);
+
+    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)))));
+    return true;
+}
+
+void AppConfig::applyServerLimits(const QJsonObject &limits)
+{
+    if (limits.isEmpty())
+        return;
+
+    auto take = [](const QJsonObject &o, const QString &key, int fallback) -> 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;
+    };
+
+    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")
+                 .arg(m_accountMin).arg(m_accountMax)
+                 .arg(m_passwordMin).arg(m_passwordMax)
+                 .arg(m_fileMax).arg(m_avatarMax));
+}
+
+void AppConfig::applyServerCdn(const QJsonObject &cdn)
+{
+    QString domain = cdn.value(QStringLiteral("domain")).toString().trimmed();
+    while (domain.endsWith(QLatin1Char('/')))
+        domain.chop(1);
+    m_cdnDomain = domain;
+    if (!m_cdnDomain.isEmpty())
+        LOG_INFO(QStringLiteral("server cdn domain=%1").arg(m_cdnDomain));
+}
+
+void AppConfig::applyServerUpdate(const QJsonObject &update)
+{
+    m_updateVersion = update.value(QStringLiteral("version")).toString().trimmed();
+    m_updateUrl = update.value(QStringLiteral("url")).toString().trimmed();
+    m_updateNotes = update.value(QStringLiteral("notes")).toString().trimmed();
+    if (!m_updateVersion.isEmpty())
+        LOG_INFO(QStringLiteral("server client version=%1 url=%2")
+                     .arg(m_updateVersion, m_updateUrl));
+}
+
+bool AppConfig::hasNewerUpdate() const
+{
+    if (m_updateVersion.isEmpty() || m_updateUrl.isEmpty())
+        return false;
+    auto parts = [](const QString &s) {
+        QList<int> out;
+        const QStringList bits = s.split(QLatin1Char('.'));
+        for (const QString &b : bits)
+            out.append(b.toInt());
+        while (out.size() < 3)
+            out.append(0);
+        return out;
+    };
+    const QList<int> remote = parts(m_updateVersion);
+    const QList<int> local = parts(m_appVersion);
+    for (int i = 0; i < 3; ++i)
+    {
+        if (remote.at(i) > local.at(i))
+            return true;
+        if (remote.at(i) < local.at(i))
+            return false;
+    }
+    return false;
+}
+
+QString AppConfig::resourceUrl(const QString &path) const
+{
+    QString p = path.trimmed();
+    if (p.startsWith(QLatin1String("http://"), Qt::CaseInsensitive)
+        || p.startsWith(QLatin1String("https://"), Qt::CaseInsensitive))
+        return p;
+    QString d = m_cdnDomain;
+    while (d.endsWith(QLatin1Char('/')))
+        d.chop(1);
+    while (p.startsWith(QLatin1Char('/')))
+        p = p.mid(1);
+    if (d.isEmpty() || p.isEmpty())
+        return {};
+    return d + QLatin1Char('/') + p;
+}
+
+bool AppConfig::validAccount(const QString &account) const
+{
+    if (account.size() < m_accountMin || account.size() > m_accountMax)
+        return false;
+    for (const QChar c : account)
+    {
+        const ushort u = c.unicode();
+        const bool alnum = (u >= '0' && u <= '9')
+            || (u >= 'A' && u <= 'Z')
+            || (u >= 'a' && u <= 'z');
+        if (!alnum)
+            return false;
+    }
+    return true;
+}
+
+bool AppConfig::validPassword(const QString &password) const
+{
+    return password.size() >= m_passwordMin && password.size() <= m_passwordMax;
+}
+
+bool AppConfig::validPhone(const QString &phone) const
+{
+    if (phone.size() != m_phoneLen || phone.isEmpty() || phone.at(0) != QLatin1Char('1'))
+        return false;
+    for (const QChar c : phone)
+    {
+        if (!c.isDigit())
+            return false;
+    }
+    return true;
+}
+
+bool AppConfig::validNickname(const QString &nickname) const
+{
+    const QString t = nickname.trimmed();
+    return !t.isEmpty() && t.size() <= m_nicknameMax;
+}
+
+bool AppConfig::validSignature(const QString &signature) const
+{
+    return signature.size() <= m_signatureMax;
+}

+ 93 - 0
Client/src/common/AppConfig.h

@@ -0,0 +1,93 @@
+#pragma once
+
+#include <QJsonObject>
+#include <QString>
+
+#include "common/i18n.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; }
+
+    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; }
+
+    // 业务限制以服务端 SetLang 下发为准;下列为连上之前的本地兜底。
+    int accountMin() const { return m_accountMin; }
+    int accountMax() const { return m_accountMax; }
+    int passwordMin() const { return m_passwordMin; }
+    int passwordMax() const { return m_passwordMax; }
+    int phoneLen() const { return m_phoneLen; }
+    int nicknameMax() const { return m_nicknameMax; }
+    int signatureMax() const { return m_signatureMax; }
+    int contentMax() const { return m_contentMax; }
+    int previewMax() const { return m_previewMax; }
+    int historyDefault() const { return m_historyDefault; }
+    int historyMax() const { return m_historyMax; }
+    int codeLen() const { return m_codeLen; }
+    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; }
+    bool hasNewerUpdate() const;
+
+    void applyServerLimits(const QJsonObject &limits);
+    void applyServerCdn(const QJsonObject &cdn);
+    void applyServerUpdate(const QJsonObject &update);
+    QString resourceUrl(const QString &path) const;
+
+    bool validAccount(const QString &account) const;
+    bool validPassword(const QString &password) const;
+    bool validPhone(const QString &phone) const;
+    bool validNickname(const QString &nickname) const;
+    bool validSignature(const QString &signature) const;
+
+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;
+    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;
+};

+ 209 - 0
Client/src/common/AppTheme.cpp

@@ -0,0 +1,209 @@
+#include "AppTheme.h"
+#include "UserSettings.h"
+
+#include <QApplication>
+#include <QCoreApplication>
+#include <QDir>
+#include <QFile>
+#include <QFont>
+#include <QFontDatabase>
+
+namespace
+{
+    QString g_systemFamily;
+    QString g_yaheiFamily;
+    QString g_alimamaFamily;
+    QString g_fontFamily;
+    AppTheme::FontPreset g_preset = AppTheme::FontSystem;
+
+    QString savedPreset()
+    {
+        return UserSettings::instance().fontPreset();
+    }
+
+    void savePreset(const QString &key)
+    {
+        UserSettings::instance().setFontPreset(key);
+    }
+
+    QString findAlimamaDir()
+    {
+        const QString appDir = QCoreApplication::applicationDirPath();
+        const QStringList relatives = {
+            QStringLiteral("../../../res/fonts/Alimama"),
+            QStringLiteral("../../res/fonts/Alimama"),
+            QStringLiteral("../res/fonts/Alimama"),
+            QStringLiteral("res/fonts/Alimama"),
+            QStringLiteral("fonts/Alimama")
+        };
+        for (const QString &rel : relatives)
+        {
+            const QDir dir(QDir::cleanPath(appDir + QLatin1Char('/') + rel));
+            if (!dir.exists())
+                continue;
+            if (!dir.entryList(QStringList() << QStringLiteral("*.ttf") << QStringLiteral("*.otf"),
+                               QDir::Files).isEmpty())
+                return dir.absolutePath();
+        }
+        return QString();
+    }
+
+    QString detectYaHei()
+    {
+        const QFontDatabase db;
+        const QStringList names = {
+            QStringLiteral("Microsoft YaHei"),
+            QStringLiteral("微软雅黑"),
+            QStringLiteral("Microsoft YaHei UI")
+        };
+        const QStringList families = db.families();
+        for (const QString &name : names)
+        {
+            for (const QString &fam : families)
+            {
+                if (fam.compare(name, Qt::CaseInsensitive) == 0)
+                    return fam;
+            }
+        }
+        return QString();
+    }
+
+    QString familyOf(AppTheme::FontPreset preset)
+    {
+        switch (preset)
+        {
+        case AppTheme::FontYaHei:
+            return g_yaheiFamily.isEmpty() ? g_systemFamily : g_yaheiFamily;
+        case AppTheme::FontAlimama:
+            return g_alimamaFamily.isEmpty() ? g_systemFamily : g_alimamaFamily;
+        case AppTheme::FontSystem:
+        default:
+            return g_systemFamily;
+        }
+    }
+
+    void applyToApp()
+    {
+        if (!qApp)
+            return;
+        QFont f(g_fontFamily, 9);
+        qApp->setFont(f);
+        qApp->setStyleSheet(AppTheme::globalQss());
+    }
+}
+
+void AppTheme::loadFonts()
+{
+    g_systemFamily = QFontDatabase::systemFont(QFontDatabase::GeneralFont).family();
+    if (g_systemFamily.isEmpty())
+        g_systemFamily = QStringLiteral("Segoe UI");
+
+    g_yaheiFamily = detectYaHei();
+
+    const QString dirPath = findAlimamaDir();
+    if (!dirPath.isEmpty())
+    {
+        const QDir dir(dirPath);
+        const QStringList files = dir.entryList(
+            QStringList() << QStringLiteral("*.ttf") << QStringLiteral("*.otf"), QDir::Files);
+        for (const QString &file : files)
+        {
+            const int id = QFontDatabase::addApplicationFont(dir.absoluteFilePath(file));
+            if (id < 0)
+                continue;
+            const QStringList families = QFontDatabase::applicationFontFamilies(id);
+            if (!families.isEmpty() && g_alimamaFamily.isEmpty())
+                g_alimamaFamily = families.first();
+        }
+    }
+
+    const QString saved = savedPreset();
+    if (saved == QLatin1String("yahei") && !g_yaheiFamily.isEmpty())
+        g_preset = FontYaHei;
+    else if (saved == QLatin1String("alimama") && !g_alimamaFamily.isEmpty())
+        g_preset = FontAlimama;
+    else
+        g_preset = FontSystem;
+
+    g_fontFamily = familyOf(g_preset);
+}
+
+QString AppTheme::fontFamily()
+{
+    return g_fontFamily.isEmpty() ? g_systemFamily : g_fontFamily;
+}
+
+AppTheme::FontPreset AppTheme::currentFontPreset()
+{
+    return g_preset;
+}
+
+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;
+        yahei.preset = FontYaHei;
+        yahei.key = QStringLiteral("yahei");
+        yahei.label = QStringLiteral("微软雅黑");
+        yahei.family = g_yaheiFamily;
+        opts.push_back(yahei);
+    }
+
+    if (!g_alimamaFamily.isEmpty())
+    {
+        FontOption ali;
+        ali.preset = FontAlimama;
+        ali.key = QStringLiteral("alimama");
+        ali.label = QStringLiteral("阿里妈妈方圆体");
+        ali.family = g_alimamaFamily;
+        opts.push_back(ali);
+    }
+    return opts;
+}
+
+void AppTheme::setFontPreset(FontPreset preset)
+{
+    const QVector<FontOption> opts = availableFonts();
+    bool ok = false;
+    for (const FontOption &opt : opts)
+    {
+        if (opt.preset == preset)
+        {
+            ok = true;
+            break;
+        }
+    }
+    if (!ok)
+        preset = FontSystem;
+
+    g_preset = preset;
+    g_fontFamily = familyOf(preset);
+
+    QString key = QStringLiteral("system");
+    if (preset == FontYaHei)
+        key = QStringLiteral("yahei");
+    else if (preset == FontAlimama)
+        key = QStringLiteral("alimama");
+    savePreset(key);
+
+    applyToApp();
+}
+
+QString AppTheme::globalQss()
+{
+    QFile f(QStringLiteral(":/qss/app.qss"));
+    if (!f.open(QFile::ReadOnly))
+        return QString();
+    QString qss = QString::fromUtf8(f.readAll());
+    qss.replace(QStringLiteral("__APP_FONT__"), fontFamily());
+    return qss;
+}

+ 46 - 0
Client/src/common/AppTheme.h

@@ -0,0 +1,46 @@
+#pragma once
+
+#include <QString>
+#include <QVector>
+
+namespace AppTheme
+{
+    static const char Primary[]        = "#07C160";
+    static const char PrimaryHover[]   = "#06AD56";
+    static const char PrimaryDown[]    = "#038D4A";
+    static const char NavBg[]          = "#2E2E2E";
+    static const char NavHover[]       = "#3E3E3E";
+    static const char PageBg[]         = "#F5F5F5";
+    static const char ChatBg[]         = "#EDEDED";
+    static const char SelfBubble[]     = "#95EC69";
+    static const char OtherBubble[]    = "#FFFFFF";
+    static const char Border[]         = "#E5E5E5";
+    static const char Text[]           = "#1A1A1A";
+    static const char SubText[]        = "#9A9A9A";
+    static const char Danger[]         = "#FA5151";
+    static const char White[]          = "#FFFFFF";
+    static const char ConvHover[]      = "#EDEDED";
+    static const char ConvSelected[]   = "#C6C6C6";
+
+    enum FontPreset
+    {
+        FontSystem = 0,
+        FontYaHei,
+        FontAlimama
+    };
+
+    struct FontOption
+    {
+        FontPreset preset;
+        QString key;
+        QString label;
+        QString family;
+    };
+
+    void loadFonts();
+    QString fontFamily();
+    FontPreset currentFontPreset();
+    QVector<FontOption> availableFonts();
+    void setFontPreset(FontPreset preset);
+    QString globalQss();
+}

+ 334 - 0
Client/src/common/LocalStore.cpp

@@ -0,0 +1,334 @@
+#include "LocalStore.h"
+#include "Logger.h"
+
+#include <algorithm>
+
+#include <QCoreApplication>
+#include <QDateTime>
+#include <QDir>
+#include <QSqlError>
+#include <QSqlQuery>
+#include <QStandardPaths>
+#include <QVariant>
+
+namespace
+{
+    const char kConn[] = "nim_local";
+
+    void logSql(const QSqlQuery &q)
+    {
+        LOG_ERROR(QStringLiteral("sqlite: %1 | %2")
+                      .arg(q.lastError().text(), q.lastQuery()));
+    }
+}
+
+LocalStore &LocalStore::instance()
+{
+    static LocalStore s;
+    return s;
+}
+
+QString LocalStore::userDir(qint64 uid)
+{
+    const QString home = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
+    return QDir(home).filePath(QStringLiteral("NIM/%1").arg(uid));
+}
+
+QString LocalStore::userSubdir(qint64 uid, const QString &name)
+{
+    if (uid <= 0 || name.isEmpty())
+        return {};
+    const QString dir = QDir(userDir(uid)).filePath(name);
+    QDir().mkpath(dir);
+    return dir;
+}
+
+bool LocalStore::openForUser(qint64 uid)
+{
+    close();
+    if (uid <= 0)
+        return false;
+
+    const QString appDir = QCoreApplication::applicationDirPath();
+    QCoreApplication::addLibraryPath(appDir);
+    QCoreApplication::addLibraryPath(QDir(appDir).filePath(QStringLiteral("sqldrivers")));
+
+    if (!QSqlDatabase::isDriverAvailable(QStringLiteral("QSQLITE")))
+    {
+        LOG_ERROR(QStringLiteral("QSQLITE driver missing, drivers=%1")
+                      .arg(QSqlDatabase::drivers().join(QLatin1Char(','))));
+        return false;
+    }
+
+    const QString dir = userDir(uid);
+    if (!QDir().mkpath(dir))
+    {
+        LOG_ERROR(QStringLiteral("mkdir failed: %1").arg(dir));
+        return false;
+    }
+
+    if (QSqlDatabase::contains(QLatin1String(kConn)))
+        m_db = QSqlDatabase::database(QLatin1String(kConn));
+    else
+        m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QLatin1String(kConn));
+    m_db.setDatabaseName(QDir(dir).filePath(QStringLiteral("msg.db")));
+    if (!m_db.open())
+    {
+        LOG_ERROR(QStringLiteral("open sqlite failed: %1").arg(m_db.lastError().text()));
+        return false;
+    }
+    m_uid = uid;
+    exec(QStringLiteral("PRAGMA journal_mode=WAL"));
+    exec(QStringLiteral("PRAGMA synchronous=NORMAL"));
+    exec(QStringLiteral("PRAGMA foreign_keys=ON"));
+    return ensureSchema();
+}
+
+void LocalStore::close()
+{
+    if (m_db.isValid() && m_db.isOpen())
+        m_db.close();
+    m_db = QSqlDatabase();
+    m_uid = 0;
+    if (QSqlDatabase::contains(QLatin1String(kConn)))
+        QSqlDatabase::removeDatabase(QLatin1String(kConn));
+}
+
+bool LocalStore::isOpen() const
+{
+    return m_db.isValid() && m_db.isOpen();
+}
+
+bool LocalStore::exec(const QString &sql) const
+{
+    if (!isOpen())
+        return false;
+    QSqlQuery q(m_db);
+    if (!q.exec(sql))
+    {
+        logSql(q);
+        return false;
+    }
+    return true;
+}
+
+void LocalStore::ensureColumn(const QString &table, const QString &column, const QString &def)
+{
+    if (!isOpen() || table.isEmpty() || column.isEmpty())
+        return;
+    QSqlQuery q(m_db);
+    if (!q.exec(QStringLiteral("PRAGMA table_info(%1)").arg(table)))
+        return;
+    while (q.next())
+    {
+        if (q.value(1).toString() == column)
+            return;
+    }
+    exec(QStringLiteral("ALTER TABLE %1 ADD COLUMN %2 %3").arg(table, column, def));
+}
+
+bool LocalStore::ensureSchema()
+{
+    const bool ok = exec(QStringLiteral(
+               "CREATE TABLE IF NOT EXISTS message ("
+               " id INTEGER PRIMARY KEY,"
+               " peer_uid INTEGER NOT NULL,"
+               " from_uid INTEGER NOT NULL,"
+               " to_uid INTEGER NOT NULL,"
+               " msg_type INTEGER NOT NULL DEFAULT 1,"
+               " content TEXT NOT NULL,"
+               " ts INTEGER NOT NULL,"
+               " status INTEGER NOT NULL DEFAULT 0)"))
+        && exec(QStringLiteral(
+               "CREATE INDEX IF NOT EXISTS idx_message_peer ON message(peer_uid, id)"))
+        && exec(QStringLiteral(
+               "CREATE TABLE IF NOT EXISTS conversation ("
+               " peer_uid INTEGER PRIMARY KEY,"
+               " name TEXT NOT NULL DEFAULT '',"
+               " preview TEXT NOT NULL DEFAULT '',"
+               " ts INTEGER NOT NULL DEFAULT 0,"
+               " unread INTEGER NOT NULL DEFAULT 0)"));
+    if (!ok)
+        return false;
+    ensureColumn(QStringLiteral("message"), QStringLiteral("status"),
+                 QStringLiteral("INTEGER NOT NULL DEFAULT 0"));
+    return true;
+}
+
+qint64 LocalStore::allocLocalId()
+{
+    qint64 id = -QDateTime::currentMSecsSinceEpoch();
+    if (id >= 0)
+        id = -1;
+    if (!isOpen())
+        return id;
+    QSqlQuery q(m_db);
+    if (q.exec(QStringLiteral("SELECT MIN(id) FROM message")) && q.next() && !q.value(0).isNull())
+    {
+        const qint64 minId = q.value(0).toLongLong();
+        if (minId < 0 && id >= minId)
+            id = minId - 1;
+    }
+    return id;
+}
+
+bool LocalStore::upsertMessage(const Msg &msg)
+{
+    if (!isOpen() || msg.id == 0 || msg.peerUid <= 0)
+        return false;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral(
+        "INSERT OR REPLACE INTO message "
+        "(id, peer_uid, from_uid, to_uid, msg_type, content, ts, status) "
+        "VALUES (?, ?, ?, ?, ?, ?, ?, ?)"));
+    q.addBindValue(msg.id);
+    q.addBindValue(msg.peerUid);
+    q.addBindValue(msg.fromUid);
+    q.addBindValue(msg.toUid);
+    q.addBindValue(msg.msgType);
+    q.addBindValue(msg.content);
+    q.addBindValue(msg.ts);
+    q.addBindValue(msg.status);
+    if (!q.exec())
+    {
+        logSql(q);
+        return false;
+    }
+    return true;
+}
+
+bool LocalStore::removeMessage(qint64 id)
+{
+    if (!isOpen() || id == 0)
+        return false;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral("DELETE FROM message WHERE id=?"));
+    q.addBindValue(id);
+    if (!q.exec())
+    {
+        logSql(q);
+        return false;
+    }
+    return true;
+}
+
+QVector<LocalStore::Msg> LocalStore::listMessages(qint64 peerUid, int limit) const
+{
+    QVector<Msg> out;
+    if (!isOpen() || peerUid <= 0)
+        return out;
+    if (limit <= 0)
+        limit = 200;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral(
+        "SELECT id, peer_uid, from_uid, to_uid, msg_type, content, ts, status "
+        "FROM message WHERE peer_uid=? ORDER BY ts DESC, id DESC LIMIT ?"));
+    q.addBindValue(peerUid);
+    q.addBindValue(limit);
+    if (!q.exec())
+    {
+        logSql(q);
+        return out;
+    }
+    while (q.next())
+    {
+        Msg m;
+        m.id = q.value(0).toLongLong();
+        m.peerUid = q.value(1).toLongLong();
+        m.fromUid = q.value(2).toLongLong();
+        m.toUid = q.value(3).toLongLong();
+        m.msgType = q.value(4).toInt();
+        m.content = q.value(5).toString();
+        m.ts = q.value(6).toLongLong();
+        m.status = q.value(7).toInt();
+        out.push_back(m);
+    }
+    std::reverse(out.begin(), out.end());
+    return out;
+}
+
+bool LocalStore::deletePeer(qint64 peerUid)
+{
+    if (!isOpen() || peerUid <= 0)
+        return false;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral("DELETE FROM message WHERE peer_uid=?"));
+    q.addBindValue(peerUid);
+    if (!q.exec())
+    {
+        logSql(q);
+        return false;
+    }
+    q.prepare(QStringLiteral("DELETE FROM conversation WHERE peer_uid=?"));
+    q.addBindValue(peerUid);
+    if (!q.exec())
+    {
+        logSql(q);
+        return false;
+    }
+    return true;
+}
+
+bool LocalStore::upsertConversation(const Conv &conv)
+{
+    if (!isOpen() || conv.peerUid <= 0)
+        return false;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral(
+        "INSERT OR REPLACE INTO conversation (peer_uid, name, preview, ts, unread) "
+        "VALUES (?, ?, ?, ?, ?)"));
+    q.addBindValue(conv.peerUid);
+    q.addBindValue(conv.name);
+    q.addBindValue(conv.preview);
+    q.addBindValue(conv.ts);
+    q.addBindValue(conv.unread);
+    if (!q.exec())
+    {
+        logSql(q);
+        return false;
+    }
+    return true;
+}
+
+QVector<LocalStore::Conv> LocalStore::listConversations() const
+{
+    QVector<Conv> out;
+    if (!isOpen())
+        return out;
+    QSqlQuery q(m_db);
+    if (!q.exec(QStringLiteral(
+            "SELECT peer_uid, name, preview, ts, unread FROM conversation ORDER BY ts DESC")))
+    {
+        logSql(q);
+        return out;
+    }
+    while (q.next())
+    {
+        Conv c;
+        c.peerUid = q.value(0).toLongLong();
+        c.name = q.value(1).toString();
+        c.preview = q.value(2).toString();
+        c.ts = q.value(3).toLongLong();
+        c.unread = q.value(4).toInt();
+        out.push_back(c);
+    }
+    return out;
+}
+
+bool LocalStore::setUnread(qint64 peerUid, int unread)
+{
+    if (!isOpen() || peerUid <= 0)
+        return false;
+    if (unread < 0)
+        unread = 0;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral("UPDATE conversation SET unread=? WHERE peer_uid=?"));
+    q.addBindValue(unread);
+    q.addBindValue(peerUid);
+    if (!q.exec())
+    {
+        logSql(q);
+        return false;
+    }
+    return true;
+}

+ 64 - 0
Client/src/common/LocalStore.h

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <QString>
+#include <QVector>
+#include <QtSql/QSqlDatabase>
+
+namespace LocalMsg {
+constexpr int Ok = 0;
+constexpr int Sending = 1;
+constexpr int Failed = 2;
+}
+
+class LocalStore
+{
+public:
+    struct Msg
+    {
+        qint64 id = 0;
+        qint64 peerUid = 0;
+        qint64 fromUid = 0;
+        qint64 toUid = 0;
+        int msgType = 1;
+        QString content;
+        qint64 ts = 0;
+        int status = LocalMsg::Ok;
+    };
+
+    struct Conv
+    {
+        qint64 peerUid = 0;
+        QString name;
+        QString preview;
+        qint64 ts = 0;
+        int unread = 0;
+    };
+
+    static LocalStore &instance();
+
+    static QString userDir(qint64 uid);
+    static QString userSubdir(qint64 uid, const QString &name);
+
+    bool openForUser(qint64 uid);
+    void close();
+    bool isOpen() const;
+
+    qint64 allocLocalId();
+    bool upsertMessage(const Msg &msg);
+    bool removeMessage(qint64 id);
+    QVector<Msg> listMessages(qint64 peerUid, int limit = 200) const;
+    bool deletePeer(qint64 peerUid);
+
+    bool upsertConversation(const Conv &conv);
+    QVector<Conv> listConversations() const;
+    bool setUnread(qint64 peerUid, int unread);
+
+private:
+    LocalStore() = default;
+    bool exec(const QString &sql) const;
+    bool ensureSchema();
+    void ensureColumn(const QString &table, const QString &column, const QString &def);
+
+    QSqlDatabase m_db;
+    qint64 m_uid = 0;
+};

+ 138 - 0
Client/src/common/Logger.cpp

@@ -0,0 +1,138 @@
+#include "Logger.h"
+
+#include <iostream>
+
+#include <QDateTime>
+#include <QMutex>
+#include <QMutexLocker>
+
+#ifdef Q_OS_WIN
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+#include <windows.h>
+#include <io.h>
+#include <fcntl.h>
+#include <stdio.h>
+#endif
+
+namespace
+{
+    bool g_enabled = false;
+    Logger::Level g_minLevel = Logger::Debug;
+    bool g_consoleReady = false;
+    QMutex g_mu;
+
+    void ensureConsole()
+    {
+        if (g_consoleReady)
+            return;
+#ifdef Q_OS_WIN
+        HWND wnd = GetConsoleWindow();
+        if (!wnd)
+        {
+            if (!AttachConsole(ATTACH_PARENT_PROCESS))
+            {
+#ifdef QT_DEBUG
+                AllocConsole();
+#else
+                // Release 是 Windows 子系统:双击不要弹出黑框。
+                // 从 cmd/PowerShell 启动时 AttachConsole 已成功,日志仍打到该终端。
+                g_consoleReady = true;
+                return;
+#endif
+            }
+        }
+        FILE *fp = nullptr;
+        freopen_s(&fp, "CONOUT$", "w", stdout);
+        freopen_s(&fp, "CONOUT$", "w", stderr);
+        SetConsoleOutputCP(CP_UTF8);
+        std::ios::sync_with_stdio(true);
+#endif
+        g_consoleReady = true;
+    }
+}
+
+void Logger::setup(bool enabled, Level minLevel)
+{
+    QMutexLocker lock(&g_mu);
+    g_enabled = enabled;
+    g_minLevel = minLevel;
+    if (g_enabled)
+        ensureConsole();
+}
+
+bool Logger::enabled()
+{
+    return g_enabled;
+}
+
+Logger::Level Logger::minLevel()
+{
+    return g_minLevel;
+}
+
+Logger::Level Logger::parseLevel(const QString &text)
+{
+    const QString s = text.trimmed().toLower();
+    bool ok = false;
+    const int n = s.toInt(&ok);
+    if (ok && n >= Debug && n <= Error)
+        return static_cast<Level>(n);
+    if (s == QLatin1String("info"))
+        return Info;
+    if (s == QLatin1String("warn") || s == QLatin1String("warning"))
+        return Warn;
+    if (s == QLatin1String("error") || s == QLatin1String("err"))
+        return Error;
+    return Debug;
+}
+
+const char *Logger::levelName(Level level)
+{
+    switch (level)
+    {
+    case Debug: return "DEBUG";
+    case Info:  return "INFO";
+    case Warn:  return "WARN";
+    case Error: return "ERROR";
+    }
+    return "DEBUG";
+}
+
+void Logger::write(Level level, const char *tag, const QString &msg)
+{
+    if (!g_enabled || level < g_minLevel)
+        return;
+
+    const QString line = QStringLiteral("%1 [%2] [%3] %4")
+        .arg(QDateTime::currentDateTime().toString(QStringLiteral("HH:mm:ss.zzz")))
+        .arg(QLatin1String(levelName(level)), 5)
+        .arg(QLatin1String(tag ? tag : "-"))
+        .arg(msg);
+
+    QMutexLocker lock(&g_mu);
+    if (g_enabled)
+        ensureConsole();
+    std::cout << line.toUtf8().constData() << std::endl;
+}
+
+void Logger::debug(const char *tag, const QString &msg)
+{
+    write(Debug, tag, msg);
+}
+
+void Logger::info(const char *tag, const QString &msg)
+{
+    write(Info, tag, msg);
+}
+
+void Logger::warn(const char *tag, const QString &msg)
+{
+    write(Warn, tag, msg);
+}
+
+void Logger::error(const char *tag, const QString &msg)
+{
+    write(Error, tag, msg);
+}

+ 32 - 0
Client/src/common/Logger.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include <QString>
+
+class Logger
+{
+public:
+    enum Level
+    {
+        Debug = 0,
+        Info  = 1,
+        Warn  = 2,
+        Error = 3
+    };
+
+    static void setup(bool enabled, Level minLevel);
+    static bool enabled();
+    static Level minLevel();
+    static Level parseLevel(const QString &text);
+    static const char *levelName(Level level);
+
+    static void write(Level level, const char *tag, const QString &msg);
+    static void debug(const char *tag, const QString &msg);
+    static void info(const char *tag, const QString &msg);
+    static void warn(const char *tag, const QString &msg);
+    static void error(const char *tag, const QString &msg);
+};
+
+#define LOG_DEBUG(msg) Logger::debug(__FUNCTION__, (msg))
+#define LOG_INFO(msg)  Logger::info(__FUNCTION__, (msg))
+#define LOG_WARN(msg)  Logger::warn(__FUNCTION__, (msg))
+#define LOG_ERROR(msg) Logger::error(__FUNCTION__, (msg))

+ 392 - 0
Client/src/common/UiHelpers.cpp

@@ -0,0 +1,392 @@
+#include "UiHelpers.h"
+#include "AppTheme.h"
+
+#include <QAction>
+#include <QApplication>
+#include <QCheckBox>
+#include <QGraphicsOpacityEffect>
+#include <QIcon>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPropertyAnimation>
+#include <QPushButton>
+#include <QSizePolicy>
+#include <QStyle>
+#include <QTimer>
+#include <QVBoxLayout>
+#include <QFile>
+#include <QDir>
+
+#ifdef Q_OS_WIN
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+#include <windows.h>
+#pragma comment(lib, "winmm.lib")
+#endif
+
+namespace
+{
+    class LogoMark : public QWidget
+    {
+    public:
+        explicit LogoMark(QWidget *parent = nullptr) : QWidget(parent)
+        {
+            setFixedSize(48, 48);
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            p.setRenderHint(QPainter::Antialiasing, false);
+            p.setPen(Qt::NoPen);
+            p.setBrush(QColor(255, 255, 255, 230));
+            p.drawRect(4, 8, 28, 22);
+            p.setBrush(Qt::white);
+            p.drawRect(16, 18, 28, 22);
+            p.setBrush(QColor("#FFFFFF"));
+            p.drawRect(12, 14, 6, 6);
+            p.drawRect(24, 26, 6, 6);
+        }
+    };
+
+    QPixmap eyeIcon(bool open)
+    {
+        QPixmap pm(16, 16);
+        pm.fill(Qt::transparent);
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, false);
+        p.setPen(QPen(QColor("#8A8A8A"), 1));
+        p.setBrush(Qt::NoBrush);
+        p.drawRect(1, 5, 14, 6);
+        if (open)
+        {
+            p.setBrush(QColor("#8A8A8A"));
+            p.drawRect(6, 7, 4, 2);
+        }
+        else
+        {
+            p.drawLine(3, 13, 13, 3);
+        }
+        return pm;
+    }
+}
+
+namespace UiHelpers
+{
+    void polish(QWidget *w)
+    {
+        if (!w)
+            return;
+        w->style()->unpolish(w);
+        w->style()->polish(w);
+        w->update();
+    }
+
+    QPushButton *primaryButton(const QString &text, QWidget *parent)
+    {
+        auto *btn = new QPushButton(text, parent);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFixedHeight(36);
+        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; }"
+            "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; }"));
+        return btn;
+    }
+
+    QPushButton *ghostButton(const QString &text, QWidget *parent)
+    {
+        auto *btn = new QPushButton(text, parent);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFixedHeight(32);
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background-color:#FFFFFF; color:#1A1A1A; border:1px solid #D9D9D9;"
+            " padding:7px 14px; border-radius:2px; }"
+            "QPushButton:hover { background-color:#F5F5F5; }"
+            "QPushButton:pressed { background-color:#EBEBEB; }"));
+        return btn;
+    }
+
+    QPushButton *linkButton(const QString &text, QWidget *parent)
+    {
+        auto *btn = new QPushButton(text, parent);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFlat(true);
+        btn->setFocusPolicy(Qt::NoFocus);
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; color:#576B95; border:none; padding:0; font-size:13px; }"
+            "QPushButton:hover { color:#1A1A1A; }"));
+        return btn;
+    }
+
+    QPushButton *dangerButton(const QString &text, QWidget *parent)
+    {
+        auto *btn = new QPushButton(text, parent);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFixedHeight(32);
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background-color:#FFFFFF; color:#FA5151; border:1px solid #FA5151;"
+            " padding:7px 14px; border-radius:2px; }"
+            "QPushButton:hover { background-color:#FFF1F1; }"));
+        return btn;
+    }
+
+    QPushButton *compactButton(const QString &text, QWidget *parent, bool primary)
+    {
+        auto *btn = new QPushButton(text, parent);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFixedHeight(28);
+        btn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+        if (primary)
+        {
+            btn->setStyleSheet(QStringLiteral(
+                "QPushButton { background-color:#2E2E2E; color:#FFFFFF; border:1px solid #2E2E2E;"
+                " font-size:12px; border-radius:2px; padding:2px 14px; }"
+                "QPushButton:hover { background-color:#3E3E3E; }"
+                "QPushButton:disabled { background-color:#C8C8C8; border-color:#C8C8C8; color:#FFFFFF; }"));
+        }
+        else
+        {
+            btn->setStyleSheet(QStringLiteral(
+                "QPushButton { background-color:#FFFFFF; color:#1A1A1A; border:1px solid #D9D9D9;"
+                " font-size:12px; border-radius:2px; padding:2px 14px; }"
+                "QPushButton:hover { background-color:#F5F5F5; }"));
+        }
+        return btn;
+    }
+
+    QLineEdit *lineEdit(const QString &placeholder, QWidget *parent)
+    {
+        auto *edit = new QLineEdit(parent);
+        edit->setPlaceholderText(placeholder);
+        edit->setFixedHeight(36);
+        edit->setClearButtonEnabled(true);
+        return edit;
+    }
+
+    QLineEdit *passwordEdit(const QString &placeholder, QWidget *parent)
+    {
+        auto *edit = new QLineEdit(parent);
+        edit->setPlaceholderText(placeholder);
+        edit->setFixedHeight(36);
+        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;
+    }
+
+    QLabel *errorLabel(QWidget *parent)
+    {
+        auto *lab = new QLabel(parent);
+        lab->setStyleSheet(QStringLiteral("color:#FA5151; font-size:12px;"));
+        lab->setWordWrap(true);
+        lab->hide();
+        return lab;
+    }
+
+    QLabel *hintLabel(const QString &text, QWidget *parent)
+    {
+        auto *lab = new QLabel(text, parent);
+        lab->setStyleSheet(QStringLiteral("color:#9A9A9A; font-size:12px;"));
+        lab->setWordWrap(true);
+        return lab;
+    }
+
+    QLabel *formTitle(const QString &text, QWidget *parent)
+    {
+        auto *lab = new QLabel(text, parent);
+        lab->setStyleSheet(QStringLiteral("color:#1A1A1A; font-size:16px; font-weight:bold;"));
+        return lab;
+    }
+
+    QWidget *authHeader(const QString &subtitle, QWidget *parent)
+    {
+        auto *header = new QWidget(parent);
+        header->setFixedHeight(100);
+        header->setStyleSheet(QStringLiteral("background:%1;").arg(AppTheme::NavBg));
+
+        auto *logo = new LogoMark(header);
+        auto *title = new QLabel(QStringLiteral("NIM"), header);
+        title->setStyleSheet(QStringLiteral("color:#FFFFFF; font-size:26px; font-weight:bold; background:transparent;"));
+        auto *sub = new QLabel(subtitle, header);
+        sub->setObjectName(QStringLiteral("AuthHeaderSubtitle"));
+        sub->setStyleSheet(QStringLiteral("color:rgba(255,255,255,210); font-size:12px; background:transparent;"));
+
+        auto *col = new QVBoxLayout(header);
+        col->setContentsMargins(0, 12, 0, 12);
+        col->setSpacing(4);
+        col->addWidget(logo, 0, Qt::AlignHCenter);
+        col->addWidget(title, 0, Qt::AlignHCenter);
+        col->addWidget(sub, 0, Qt::AlignHCenter);
+        return header;
+    }
+
+    void setAuthHeaderSubtitle(QWidget *header, const QString &text)
+    {
+        if (!header)
+            return;
+        if (auto *lab = header->findChild<QLabel *>(QStringLiteral("AuthHeaderSubtitle")))
+            lab->setText(text);
+    }
+
+    QColor colorFromName(const QString &name)
+    {
+        static const QColor palette[] = {
+            QColor("#07C160"), QColor("#10AEFF"), QColor("#576B95"),
+            QColor("#FA9D3B"), QColor("#F76260"), QColor("#9475CA"),
+            QColor("#2AAE67"), QColor("#1485EE")
+        };
+        uint h = 0;
+        for (QChar c : name)
+            h = 31 * h + c.unicode();
+        return palette[h % 8];
+    }
+
+    QPixmap avatarPixmap(const QString &name, int size, bool square)
+    {
+        Q_UNUSED(square);
+        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+        QPixmap pm(qMax(1, qRound(size * dpr)), qMax(1, qRound(size * dpr)));
+        pm.fill(Qt::transparent);
+        pm.setDevicePixelRatio(dpr);
+
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.scale(dpr, dpr);
+
+        const qreal radius = 4.0;
+        QPainterPath path;
+        path.addRoundedRect(QRectF(0.5, 0.5, size - 1.0, size - 1.0), radius, radius);
+        p.fillPath(path, colorFromName(name));
+
+        QString text = name.trimmed();
+        if (text.size() > 2)
+            text = text.right(2);
+        QFont f(AppTheme::fontFamily());
+        f.setPixelSize(qMax(11, size * 11 / 32));
+        f.setBold(true);
+        p.setFont(f);
+        p.setPen(Qt::white);
+        p.drawText(QRectF(0, 0, size, size), Qt::AlignCenter, text);
+        return pm;
+    }
+
+    QPixmap roundAvatar(const QPixmap &src, int size)
+    {
+        if (src.isNull() || size <= 0)
+            return QPixmap();
+        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+        const int px = qMax(1, qRound(size * dpr));
+        QPixmap pm(px, px);
+        pm.fill(Qt::transparent);
+        pm.setDevicePixelRatio(dpr);
+
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.setRenderHint(QPainter::SmoothPixmapTransform, true);
+        p.scale(dpr, dpr);
+
+        QPainterPath path;
+        path.addRoundedRect(QRectF(0.5, 0.5, size - 1.0, size - 1.0), 4.0, 4.0);
+        p.setClipPath(path);
+        const QPixmap scaled = src.scaled(size, size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
+        const int x = (size - scaled.width()) / 2;
+        const int y = (size - scaled.height()) / 2;
+        p.drawPixmap(x, y, scaled);
+        return pm;
+    }
+
+    QIcon appIcon()
+    {
+        return QIcon(QStringLiteral(":/icons/app.ico"));
+    }
+
+    void playNotifySound()
+    {
+#ifdef Q_OS_WIN
+        static QString path;
+        if (path.isEmpty())
+        {
+            const QString tmp = QDir::temp().filePath(QStringLiteral("im_notify.wav"));
+            if (QFile::exists(tmp))
+                path = tmp;
+            else if (QFile::copy(QStringLiteral(":/sounds/notify.wav"), tmp))
+            {
+                QFile::setPermissions(tmp, QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser);
+                path = tmp;
+            }
+        }
+        if (!path.isEmpty())
+            PlaySoundW(reinterpret_cast<LPCWSTR>(path.utf16()), nullptr,
+                       SND_FILENAME | SND_ASYNC | SND_NODEFAULT);
+#endif
+    }
+
+    void showToast(QWidget *parent, const QString &text, bool error)
+    {
+        if (!parent)
+            return;
+
+        auto *toast = new QLabel(text, parent);
+        toast->setAlignment(Qt::AlignCenter);
+        toast->setWordWrap(true);
+        toast->setAttribute(Qt::WA_TransparentForMouseEvents);
+        toast->setStyleSheet(QStringLiteral(
+            "QLabel { background:%1; color:#FFFFFF; padding:10px 18px; font-size:13px; border:none; }")
+            .arg(error ? QStringLiteral("#4C4C4C") : QStringLiteral("#4C4C4C")));
+        toast->adjustSize();
+        const int w = qMin(parent->width() - 40, qMax(180, toast->width() + 8));
+        toast->setFixedWidth(w);
+        toast->adjustSize();
+        toast->move((parent->width() - toast->width()) / 2, parent->height() - toast->height() - 36);
+        toast->show();
+        toast->raise();
+
+        auto *fade = new QGraphicsOpacityEffect(toast);
+        toast->setGraphicsEffect(fade);
+        auto *anim = new QPropertyAnimation(fade, "opacity", toast);
+        anim->setDuration(280);
+        anim->setStartValue(0.0);
+        anim->setEndValue(1.0);
+        anim->start();
+
+        QTimer::singleShot(1800, toast, [toast]() {
+            toast->deleteLater();
+        });
+    }
+
+    void setError(QLabel *label, const QString &text)
+    {
+        if (!label)
+            return;
+        if (text.isEmpty())
+        {
+            label->hide();
+            label->clear();
+            return;
+        }
+        label->setText(text);
+        label->show();
+    }
+
+    void setupFramelessDialog(QWidget *dialog)
+    {
+        if (!dialog)
+            return;
+        dialog->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint);
+        dialog->setWindowModality(Qt::WindowModal);
+    }
+}

+ 43 - 0
Client/src/common/UiHelpers.h

@@ -0,0 +1,43 @@
+#pragma once
+
+#include <QColor>
+#include <QIcon>
+#include <QPixmap>
+#include <QString>
+#include <QWidget>
+
+class QLabel;
+class QLineEdit;
+class QPushButton;
+class QCheckBox;
+
+namespace UiHelpers
+{
+    void polish(QWidget *w);
+
+    QPushButton *primaryButton(const QString &text, QWidget *parent = nullptr);
+    QPushButton *ghostButton(const QString &text, QWidget *parent = nullptr);
+    QPushButton *linkButton(const QString &text, QWidget *parent = nullptr);
+    QPushButton *dangerButton(const QString &text, QWidget *parent = nullptr);
+    QPushButton *compactButton(const QString &text, QWidget *parent = nullptr, bool primary = false);
+
+    QLineEdit *lineEdit(const QString &placeholder, QWidget *parent = nullptr);
+    QLineEdit *passwordEdit(const QString &placeholder, QWidget *parent = nullptr);
+
+    QLabel *errorLabel(QWidget *parent = nullptr);
+    QLabel *hintLabel(const QString &text, QWidget *parent = nullptr);
+    QLabel *formTitle(const QString &text, QWidget *parent = nullptr);
+
+    QWidget *authHeader(const QString &subtitle, QWidget *parent = nullptr);
+    void setAuthHeaderSubtitle(QWidget *header, const QString &text);
+
+    QPixmap avatarPixmap(const QString &name, int size, bool square = true);
+    QPixmap roundAvatar(const QPixmap &src, int size);
+    QIcon appIcon();
+    QColor colorFromName(const QString &name);
+    void playNotifySound();
+
+    void showToast(QWidget *parent, const QString &text, bool error = false);
+    void setError(QLabel *label, const QString &text);
+    void setupFramelessDialog(QWidget *dialog);
+}

+ 555 - 0
Client/src/common/Updater.cpp

@@ -0,0 +1,555 @@
+#include "Updater.h"
+#include "AppConfig.h"
+#include "AppTheme.h"
+#include "ConfirmDialog.h"
+#include "FramelessHelper.h"
+#include "HttpFetch.h"
+#include "Logger.h"
+#include "UiHelpers.h"
+#include "UserSettings.h"
+
+#include <atomic>
+#include <functional>
+#include <memory>
+
+#include <QApplication>
+#include <QCoreApplication>
+#include <QDateTime>
+#include <QDialog>
+#include <QDir>
+#include <QFile>
+#include <QFileInfo>
+#include <QFont>
+#include <QGuiApplication>
+#include <QLabel>
+#include <QMetaObject>
+#include <QPointer>
+#include <QProcess>
+#include <QProgressBar>
+#include <QPushButton>
+#include <QScreen>
+#include <QThread>
+#include <QTimer>
+#include <QVBoxLayout>
+#include <QWidget>
+
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+#include <Windows.h>
+
+namespace
+{
+    bool s_asked = false;
+
+    QString argValue(const QStringList &args, const QString &key)
+    {
+        const int idx = args.indexOf(key);
+        if (idx < 0 || idx + 1 >= args.size())
+            return QString();
+        return args.at(idx + 1);
+    }
+
+    bool waitAndKillPid(qint64 pid, int timeoutMs)
+    {
+        if (pid <= 0)
+            return true;
+        HANDLE h = OpenProcess(SYNCHRONIZE | PROCESS_TERMINATE, FALSE, static_cast<DWORD>(pid));
+        if (!h)
+            return true;
+        const DWORD waitRes = WaitForSingleObject(h, static_cast<DWORD>(qMax(0, timeoutMs)));
+        if (waitRes == WAIT_TIMEOUT)
+            TerminateProcess(h, 0);
+        CloseHandle(h);
+        return true;
+    }
+
+    bool removeDirRecursivelyWithRetry(const QString &dirPath, int retries, int sleepMs)
+    {
+        for (int i = 0; i <= retries; ++i)
+        {
+            if (!QFileInfo::exists(dirPath))
+                return true;
+            if (QDir(dirPath).removeRecursively())
+                return true;
+            QThread::msleep(static_cast<unsigned long>(sleepMs));
+        }
+        return !QFileInfo::exists(dirPath);
+    }
+
+    bool copyDirectoryRecursively(const QString &srcPath, const QString &dstPath)
+    {
+        QDir srcDir(srcPath);
+        if (!srcDir.exists())
+            return false;
+        QDir dstDir(dstPath);
+        if (!dstDir.exists() && !dstDir.mkpath(QStringLiteral(".")))
+            return false;
+
+        const QFileInfoList entries = srcDir.entryInfoList(QDir::NoDotAndDotDot | QDir::AllEntries);
+        for (const QFileInfo &entry : entries)
+        {
+            if (entry.isDir() && entry.fileName().compare(QStringLiteral("cache"), Qt::CaseInsensitive) == 0)
+                continue;
+            const QString srcItem = entry.absoluteFilePath();
+            const QString dstItem = dstDir.filePath(entry.fileName());
+            if (entry.isDir())
+            {
+                if (!copyDirectoryRecursively(srcItem, dstItem))
+                    return false;
+            }
+            else
+            {
+                if (QFile::exists(dstItem) && !QFile::remove(dstItem))
+                    return false;
+                if (!QFile::copy(srcItem, dstItem))
+                    return false;
+            }
+        }
+        return true;
+    }
+
+    QString bundled7zaExePath()
+    {
+        const QString base = QDir(QCoreApplication::applicationDirPath())
+                                 .filePath(QStringLiteral("3rdparty/7z"));
+        const QStringList candidates = {
+            QDir(base).filePath(QStringLiteral("x64/7za.exe")),
+            QDir(base).filePath(QStringLiteral("7za.exe")),
+        };
+        for (const QString &candidate : candidates)
+        {
+            const QString abs = QFileInfo(candidate).absoluteFilePath();
+            if (QFileInfo::exists(abs))
+                return abs;
+        }
+        return QString();
+    }
+
+    const QString kBarQss = QStringLiteral(
+        "QProgressBar { background:#F0F0F0; border:none; border-radius:4px; }"
+        "QProgressBar::chunk { background:#07C160; border-radius:4px; }");
+
+    int parseLastPercent(const QByteArray &buf)
+    {
+        int best = -1;
+        for (int i = 0; i < buf.size(); ++i)
+        {
+            if (buf.at(i) != '%')
+                continue;
+            int s = i - 1;
+            while (s >= 0 && buf.at(s) >= '0' && buf.at(s) <= '9')
+                --s;
+            if (s + 1 >= i)
+                continue;
+            bool ok = false;
+            const int n = buf.mid(s + 1, i - s - 1).toInt(&ok);
+            if (ok && n >= 0 && n <= 100)
+                best = n;
+        }
+        return best;
+    }
+
+    using PctFn = std::function<void(int)>;
+
+    void hideChildConsole(QProcess &proc)
+    {
+        proc.setCreateProcessArgumentsModifier([](QProcess::CreateProcessArguments *args) {
+            args->flags |= CREATE_NO_WINDOW;
+        });
+    }
+
+    bool unzipWith7za(const QString &zip, const QString &dest, const PctFn &onPct)
+    {
+        const QString sevenZa = bundled7zaExePath();
+        if (sevenZa.isEmpty())
+            return false;
+        QProcess proc;
+        QStringList args;
+        args << QStringLiteral("x")
+             << QDir::toNativeSeparators(zip)
+             << (QStringLiteral("-o") + QDir::toNativeSeparators(dest))
+             << QStringLiteral("-y")
+             << QStringLiteral("-aoa")
+             << QStringLiteral("-bsp1");
+        proc.setProcessChannelMode(QProcess::MergedChannels);
+        hideChildConsole(proc);
+        proc.start(sevenZa, args);
+        if (!proc.waitForStarted(4000))
+            return false;
+        QByteArray buf;
+        const qint64 deadline = QDateTime::currentMSecsSinceEpoch() + 180000;
+        while (proc.state() != QProcess::NotRunning)
+        {
+            if (QDateTime::currentMSecsSinceEpoch() > deadline)
+            {
+                proc.kill();
+                proc.waitForFinished(2000);
+                return false;
+            }
+            proc.waitForReadyRead(200);
+            buf += proc.readAll();
+            const int pct = parseLastPercent(buf);
+            if (pct >= 0 && onPct)
+                onPct(pct);
+            if (buf.size() > 4096)
+                buf = buf.right(512);
+        }
+        if (onPct)
+            onPct(100);
+        return proc.exitStatus() == QProcess::NormalExit && proc.exitCode() == 0;
+    }
+
+    bool unzipInto(const QString &zip, const QString &dest, const PctFn &onPct = PctFn())
+    {
+        QDir().mkpath(dest);
+        if (unzipWith7za(zip, dest, onPct))
+            return true;
+        if (onPct)
+            onPct(40);
+        QProcess tar;
+        hideChildConsole(tar);
+        tar.start(QStringLiteral("tar"),
+                  QStringList() << QStringLiteral("-xf") << zip << QStringLiteral("-C") << dest);
+        if (tar.waitForFinished(180000) && tar.exitStatus() == QProcess::NormalExit && tar.exitCode() == 0)
+        {
+            if (onPct)
+                onPct(100);
+            return true;
+        }
+        QProcess ps;
+        const QString cmd = QStringLiteral(
+                                "Expand-Archive -LiteralPath '%1' -DestinationPath '%2' -Force")
+                                .arg(zip, dest);
+        hideChildConsole(ps);
+        ps.start(QStringLiteral("powershell"),
+                 QStringList() << QStringLiteral("-NoProfile") << QStringLiteral("-Command") << cmd);
+        const bool ok = ps.waitForFinished(180000) && ps.exitStatus() == QProcess::NormalExit
+                        && ps.exitCode() == 0;
+        if (ok && onPct)
+            onPct(100);
+        return ok;
+    }
+
+    struct UpdateChrome
+    {
+        QDialog *dlg = nullptr;
+        QLabel *lab = nullptr;
+        QProgressBar *bar = nullptr;
+        QLabel *hint = nullptr;
+        QPushButton *cancel = nullptr;
+    };
+
+    UpdateChrome makeUpdateChrome(QWidget *parent, const QString &statusText, bool withCancel)
+    {
+        UpdateChrome ui;
+        ui.dlg = new QDialog(parent);
+        ui.dlg->setObjectName(QStringLiteral("ChromeWindow"));
+        ui.dlg->setWindowTitle(QStringLiteral("正在更新"));
+        ui.dlg->setFixedWidth(320);
+        UiHelpers::setupFramelessDialog(ui.dlg);
+        if (!parent)
+            ui.dlg->setWindowModality(Qt::ApplicationModal);
+        new FramelessHelper(ui.dlg, nullptr, false);
+
+        auto *lay = new QVBoxLayout(ui.dlg);
+        lay->setContentsMargins(24, 22, 24, 18);
+        lay->setSpacing(10);
+        ui.lab = new QLabel(statusText, ui.dlg);
+        ui.lab->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
+        ui.bar = new QProgressBar(ui.dlg);
+        ui.bar->setRange(0, 1000);
+        ui.bar->setValue(0);
+        ui.bar->setTextVisible(false);
+        ui.bar->setFixedHeight(8);
+        ui.bar->setStyleSheet(kBarQss);
+        ui.hint = new QLabel(QStringLiteral("0%"), ui.dlg);
+        ui.hint->setStyleSheet(QStringLiteral("color:#888; font-size:12px; background:transparent;"));
+        lay->addWidget(ui.lab);
+        lay->addWidget(ui.bar);
+        lay->addWidget(ui.hint);
+        if (withCancel)
+        {
+            ui.cancel = new QPushButton(QStringLiteral("取消"), ui.dlg);
+            ui.cancel->setCursor(Qt::PointingHandCursor);
+            lay->addWidget(ui.cancel, 0, Qt::AlignRight);
+        }
+        else
+        {
+            auto *note = new QLabel(QStringLiteral("请勿关闭程序"), ui.dlg);
+            note->setStyleSheet(QStringLiteral("color:#888; font-size:12px; background:transparent;"));
+            lay->addWidget(note, 0, Qt::AlignRight);
+        }
+        return ui;
+    }
+
+    void centerOnScreen(QWidget *w)
+    {
+        if (!w)
+            return;
+        QScreen *screen = QGuiApplication::primaryScreen();
+        if (!screen)
+            return;
+        const QRect ag = screen->availableGeometry();
+        w->move(ag.center() - w->rect().center());
+    }
+
+    void flattenNestedPayload(const QString &targetDir)
+    {
+        const QString exeName = QFileInfo(QCoreApplication::applicationFilePath()).fileName();
+        QDir d(targetDir);
+        if (QFile::exists(d.filePath(exeName)))
+            return;
+        const QStringList dirs = d.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
+        if (dirs.size() != 1)
+            return;
+        const QString inner = d.filePath(dirs.first());
+        if (!QFile::exists(QDir(inner).filePath(exeName)))
+            return;
+        copyDirectoryRecursively(inner, targetDir);
+        QDir(inner).removeRecursively();
+    }
+
+    int runApplyUpdateMode(const QStringList &args, const std::function<void(int, QString)> &progress)
+    {
+        const QString targetDir = argValue(args, QStringLiteral("--target-dir"));
+        const QString targetExe = argValue(args, QStringLiteral("--target-exe"));
+        const QString zipList = argValue(args, QStringLiteral("--zip-list"));
+        const QString runnerDir = argValue(args, QStringLiteral("--runner-dir"));
+        const qint64 parentPid = argValue(args, QStringLiteral("--parent-pid")).toLongLong();
+        if (targetDir.isEmpty() || targetExe.isEmpty() || zipList.isEmpty() || runnerDir.isEmpty())
+            return 2;
+
+        if (progress)
+            progress(0, QStringLiteral("正在关闭旧版本 …"));
+        waitAndKillPid(parentPid, 120000);
+
+        if (progress)
+            progress(50, QStringLiteral("正在安装更新 …"));
+        const QStringList zipPaths = zipList.split(QLatin1Char('|'), QString::SkipEmptyParts);
+        const int zipCount = qMax(1, zipPaths.size());
+        for (int i = 0; i < zipPaths.size(); ++i)
+        {
+            const int base = 50 + (900 * i) / zipCount;
+            const int span = 900 / zipCount;
+            if (!unzipInto(zipPaths.at(i), targetDir, [&](int pct) {
+                    if (progress)
+                        progress(base + span * qBound(0, pct, 100) / 100,
+                                 QStringLiteral("正在安装更新 …"));
+                }))
+                return 3;
+        }
+        flattenNestedPayload(targetDir);
+        for (const QString &zipPath : zipPaths)
+            QFile::remove(zipPath);
+
+        if (progress)
+            progress(980, QStringLiteral("即将重启 …"));
+        const qint64 updaterPid = QCoreApplication::applicationPid();
+        const QStringList startArgs{
+            QStringLiteral("--cleanup-update"),
+            QStringLiteral("--cleanup-dir"), runnerDir,
+            QStringLiteral("--cleanup-pid"), QString::number(updaterPid)};
+        if (!QProcess::startDetached(targetExe, startArgs, targetDir))
+            return 4;
+        if (progress)
+            progress(1000, QStringLiteral("正在启动新版本 …"));
+        return 0;
+    }
+
+    bool runSelfUpdaterAndExit(const QStringList &zipPathsNative)
+    {
+        if (zipPathsNative.isEmpty())
+            return false;
+
+        const QString appDir = QCoreApplication::applicationDirPath();
+        const QString exePath = QCoreApplication::applicationFilePath();
+        const qint64 parentPid = QCoreApplication::applicationPid();
+        const QString exeName = QFileInfo(exePath).fileName();
+        const QString runnerRoot = QDir::temp().filePath(
+            QStringLiteral("nim_updater_%1").arg(QDateTime::currentMSecsSinceEpoch()));
+        const QString runnerAppDir = QDir(runnerRoot).filePath(QStringLiteral("app"));
+
+        if (!QDir().mkpath(runnerAppDir))
+        {
+            LOG_ERROR(QStringLiteral("create updater temp dir failed"));
+            return false;
+        }
+        if (!copyDirectoryRecursively(appDir, runnerAppDir))
+        {
+            LOG_ERROR(QStringLiteral("copy app for updater failed"));
+            return false;
+        }
+
+        const QString copiedExePath = QDir(runnerAppDir).filePath(exeName);
+        if (!QFileInfo::exists(copiedExePath))
+        {
+            LOG_ERROR(QStringLiteral("copied updater exe missing"));
+            return false;
+        }
+
+        QStringList args;
+        args << QStringLiteral("--apply-update")
+             << QStringLiteral("--target-dir") << appDir
+             << QStringLiteral("--target-exe") << exePath
+             << QStringLiteral("--parent-pid") << QString::number(parentPid)
+             << QStringLiteral("--runner-dir") << runnerRoot
+             << QStringLiteral("--zip-list") << zipPathsNative.join(QLatin1Char('|'));
+
+        if (!QProcess::startDetached(copiedExePath, args, runnerAppDir))
+        {
+            LOG_ERROR(QStringLiteral("start self updater failed"));
+            return false;
+        }
+        return true;
+    }
+
+    void runDownload(QWidget *host, const QString &url, const QString &ver)
+    {
+        UpdateChrome ui = makeUpdateChrome(host, QStringLiteral("正在下载 %1 …").arg(ver), true);
+        ui.dlg->setAttribute(Qt::WA_DeleteOnClose);
+
+        auto cancelFlag = std::make_shared<std::atomic<bool>>(false);
+        const QString zip = QDir::temp().filePath(
+            QStringLiteral("nim-update-%1.zip").arg(QDateTime::currentMSecsSinceEpoch()));
+        QFile::remove(zip);
+
+        QObject::connect(ui.cancel, &QPushButton::clicked, ui.dlg, [cancelFlag, dlg = ui.dlg]() {
+            cancelFlag->store(true);
+            dlg->reject();
+        });
+
+        QPointer<QDialog> dlgRef(ui.dlg);
+        QPointer<QProgressBar> barRef(ui.bar);
+        QPointer<QLabel> hintRef(ui.hint);
+        QPointer<QWidget> hostRef(host);
+        HttpFetch::instance().download(
+            url, zip, 0, 0, cancelFlag,
+            [barRef, hintRef](qint64 rec, qint64 tot) {
+                if (!barRef)
+                    return;
+                if (tot > 0)
+                {
+                    barRef->setValue(static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
+                    if (hintRef)
+                        hintRef->setText(QStringLiteral("%1%").arg(rec * 100 / tot));
+                }
+            },
+            [dlgRef, hostRef, zip, ver](bool ok, const QString &error) {
+                if (!dlgRef)
+                    return;
+                if (!ok)
+                {
+                    QFile::remove(zip);
+                    dlgRef->close();
+                    if (hostRef)
+                        UiHelpers::showToast(hostRef,
+                                             error.isEmpty() ? QStringLiteral("下载失败") : error, true);
+                    return;
+                }
+                if (!runSelfUpdaterAndExit(QStringList() << QDir::toNativeSeparators(zip)))
+                {
+                    QFile::remove(zip);
+                    dlgRef->close();
+                    if (hostRef)
+                        UiHelpers::showToast(hostRef, QStringLiteral("启动更新程序失败"), true);
+                    return;
+                }
+                LOG_INFO(QStringLiteral("applying update %1").arg(ver));
+                dlgRef->close();
+                // 立刻结束,尽快放开 NIM.exe / DLL,方便副本解压覆盖。
+                ::ExitProcess(0);
+            });
+        ui.dlg->exec();
+    }
+}
+
+bool Updater::runApplyUpdateIfRequested(int argc, char **argv, int *exitCode)
+{
+    QStringList args;
+    args.reserve(argc);
+    for (int i = 0; i < argc; ++i)
+        args.append(QString::fromLocal8Bit(argv[i]));
+    if (!args.contains(QStringLiteral("--apply-update")))
+        return false;
+
+    QApplication updaterApp(argc, argv);
+    UserSettings::instance().load();
+    AppTheme::loadFonts();
+    updaterApp.setFont(QFont(AppTheme::fontFamily(), 9));
+    updaterApp.setStyleSheet(AppTheme::globalQss());
+    updaterApp.setApplicationName(QStringLiteral("NIM"));
+    updaterApp.setOrganizationName(QStringLiteral("NIM"));
+    updaterApp.setWindowIcon(UiHelpers::appIcon());
+    updaterApp.setQuitOnLastWindowClosed(false);
+
+    UpdateChrome ui = makeUpdateChrome(nullptr, QStringLiteral("正在安装更新 …"), false);
+    ui.dlg->adjustSize();
+    ui.dlg->show();
+    centerOnScreen(ui.dlg);
+    ui.dlg->raise();
+    ui.dlg->activateWindow();
+    updaterApp.processEvents();
+
+    QPointer<QDialog> dlgRef(ui.dlg);
+    QPointer<QLabel> labRef(ui.lab);
+    QPointer<QProgressBar> barRef(ui.bar);
+    QPointer<QLabel> hintRef(ui.hint);
+    QThread *worker = QThread::create([args, appPtr = &updaterApp, dlgRef, labRef, barRef, hintRef]() {
+        const int rc = runApplyUpdateMode(args, [=](int permille, const QString &status) {
+            QMetaObject::invokeMethod(appPtr, [=]() {
+                if (labRef && !status.isEmpty())
+                    labRef->setText(status);
+                if (barRef)
+                    barRef->setValue(qBound(0, permille, 1000));
+                if (hintRef)
+                    hintRef->setText(QStringLiteral("%1%").arg(qBound(0, permille, 1000) / 10));
+            }, Qt::QueuedConnection);
+        });
+        QMetaObject::invokeMethod(appPtr, [appPtr, rc, dlgRef]() {
+            if (dlgRef)
+                dlgRef->close();
+            appPtr->exit(rc);
+        }, Qt::QueuedConnection);
+    });
+    QObject::connect(worker, &QThread::finished, worker, &QObject::deleteLater);
+    QTimer::singleShot(50, &updaterApp, [worker]() { worker->start(); });
+    const int rc = updaterApp.exec();
+    if (exitCode)
+        *exitCode = rc;
+    return true;
+}
+
+void Updater::scheduleCleanupAfterStart()
+{
+    if (!qApp)
+        return;
+    const QStringList args = qApp->arguments();
+    if (!args.contains(QStringLiteral("--cleanup-update")))
+        return;
+    const QString cleanupDir = argValue(args, QStringLiteral("--cleanup-dir"));
+    const qint64 cleanupPid = argValue(args, QStringLiteral("--cleanup-pid")).toLongLong();
+    QTimer::singleShot(2000, qApp, [cleanupDir, cleanupPid]() {
+        waitAndKillPid(cleanupPid, 2000);
+        removeDirRecursivelyWithRetry(cleanupDir, 8, 500);
+    });
+}
+
+void Updater::promptIfNeeded(QWidget *host)
+{
+    if (s_asked || !host)
+        return;
+    const auto &cfg = AppConfig::instance();
+    if (!cfg.hasNewerUpdate())
+        return;
+    s_asked = true;
+    QString text = QStringLiteral("当前 %1,最新 %2。")
+                       .arg(cfg.appVersion(), cfg.updateVersion());
+    if (!cfg.updateNotes().isEmpty())
+        text += QLatin1Char('\n') + cfg.updateNotes();
+    const int act = ConfirmDialog::chooseAction(host, QStringLiteral("发现新版本"), text,
+                                                QStringLiteral("稍后"), QStringLiteral("立即更新"));
+    if (act != 2)
+        return;
+    runDownload(host, cfg.updateUrl(), cfg.updateVersion());
+}

+ 13 - 0
Client/src/common/Updater.h

@@ -0,0 +1,13 @@
+#pragma once
+
+class QWidget;
+
+class Updater
+{
+public:
+    // 副本进程:解压覆盖安装目录后拉起原 exe。处理了则返回 true,exitCode 为进程退出码。
+    static bool runApplyUpdateIfRequested(int argc, char **argv, int *exitCode);
+    // 新进程启动后删掉临时副本目录。
+    static void scheduleCleanupAfterStart();
+    static void promptIfNeeded(QWidget *host);
+};

+ 170 - 0
Client/src/common/UserSettings.cpp

@@ -0,0 +1,170 @@
+#include "UserSettings.h"
+#include "AppConfig.h"
+
+#include <QCoreApplication>
+#include <QDir>
+#include <QFileInfo>
+#include <QRect>
+#include <QSettings>
+#include <QtGlobal>
+
+namespace
+{
+    const char kXor[] = "IM-Client-local";
+
+    QString encodeSecret(const QString &plain)
+    {
+        if (plain.isEmpty())
+            return QString();
+        QByteArray raw = plain.toUtf8();
+        const int kn = static_cast<int>(sizeof(kXor) - 1);
+        for (int i = 0; i < raw.size(); ++i)
+            raw[i] = raw[i] ^ kXor[i % kn];
+        return QString::fromLatin1(raw.toBase64());
+    }
+
+    QString decodeSecret(const QString &stored)
+    {
+        if (stored.isEmpty())
+            return QString();
+        QByteArray raw = QByteArray::fromBase64(stored.toLatin1());
+        const int kn = static_cast<int>(sizeof(kXor) - 1);
+        for (int i = 0; i < raw.size(); ++i)
+            raw[i] = raw[i] ^ kXor[i % kn];
+        return QString::fromUtf8(raw);
+    }
+
+    void applyIniCodec(QSettings &ini)
+    {
+#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
+        ini.setIniCodec("UTF-8");
+#else
+        Q_UNUSED(ini);
+#endif
+    }
+}
+
+UserSettings &UserSettings::instance()
+{
+    static UserSettings s;
+    return s;
+}
+
+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");
+
+    QSettings ini(m_path, QSettings::IniFormat);
+    applyIniCodec(ini);
+
+    m_account = ini.value(QStringLiteral("login/account")).toString().trimmed();
+    m_nickname = ini.value(QStringLiteral("login/nickname")).toString().trimmed();
+    m_rememberPassword = ini.value(QStringLiteral("login/remember_password"), false).toBool();
+    m_autoLogin = ini.value(QStringLiteral("login/auto_login"), false).toBool();
+    if (m_rememberPassword)
+        m_password = decodeSecret(ini.value(QStringLiteral("login/password")).toString());
+    else
+        m_password.clear();
+    if (!m_rememberPassword)
+        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();
+    const int w = ini.value(QStringLiteral("window/w"), 0).toInt();
+    const int h = ini.value(QStringLiteral("window/h"), 0).toInt();
+    if (w > 0 && h > 0)
+        m_windowRect = QRect(x, y, w, h);
+    m_windowMaximized = ini.value(QStringLiteral("window/maximized"), false).toBool();
+    const QString closeAct = ini.value(QStringLiteral("window/close_action")).toString().trimmed();
+    if (closeAct == QLatin1String("tray"))
+        m_closeAction = 1;
+    else if (closeAct == QLatin1String("quit"))
+        m_closeAction = 2;
+    else
+        m_closeAction = 0;
+    return true;
+}
+
+void UserSettings::persist() const
+{
+    if (m_path.isEmpty())
+        return;
+
+    QSettings ini(m_path, QSettings::IniFormat);
+    applyIniCodec(ini);
+    ini.setValue(QStringLiteral("login/account"), m_account);
+    ini.setValue(QStringLiteral("login/nickname"), m_nickname);
+    ini.setValue(QStringLiteral("login/remember_password"), m_rememberPassword);
+    ini.setValue(QStringLiteral("login/auto_login"), m_autoLogin && m_rememberPassword);
+    ini.setValue(QStringLiteral("login/password"),
+                 m_rememberPassword ? encodeSecret(m_password) : QString());
+    ini.setValue(QStringLiteral("ui/font_preset"), m_fontPreset);
+    if (m_windowRect.isValid())
+    {
+        ini.setValue(QStringLiteral("window/x"), m_windowRect.x());
+        ini.setValue(QStringLiteral("window/y"), m_windowRect.y());
+        ini.setValue(QStringLiteral("window/w"), m_windowRect.width());
+        ini.setValue(QStringLiteral("window/h"), m_windowRect.height());
+    }
+    ini.setValue(QStringLiteral("window/maximized"), m_windowMaximized);
+    if (m_closeAction == 1)
+        ini.setValue(QStringLiteral("window/close_action"), QStringLiteral("tray"));
+    else if (m_closeAction == 2)
+        ini.setValue(QStringLiteral("window/close_action"), QStringLiteral("quit"));
+    else
+        ini.remove(QStringLiteral("window/close_action"));
+    ini.sync();
+}
+
+void UserSettings::saveLogin(const QString &account, const QString &nickname,
+                             const QString &password, bool rememberPassword, bool autoLogin)
+{
+    m_account = account.trimmed();
+    m_nickname = nickname.trimmed();
+    m_rememberPassword = rememberPassword && !password.isEmpty();
+    m_autoLogin = autoLogin && m_rememberPassword;
+    m_password = m_rememberPassword ? password : QString();
+    persist();
+}
+
+void UserSettings::setFontPreset(const QString &key)
+{
+    m_fontPreset = key.isEmpty() ? QStringLiteral("system") : key;
+    persist();
+}
+
+void UserSettings::saveWindow(const QRect &rect, bool maximized)
+{
+    if (rect.width() > 0 && rect.height() > 0)
+        m_windowRect = rect;
+    m_windowMaximized = maximized;
+    persist();
+}
+
+void UserSettings::setCloseAction(int action)
+{
+    m_closeAction = (action == 1 || action == 2) ? action : 0;
+    persist();
+}

+ 48 - 0
Client/src/common/UserSettings.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <QRect>
+#include <QString>
+
+class UserSettings
+{
+public:
+    static UserSettings &instance();
+
+    bool load();
+    QString path() const { return m_path; }
+
+    QString lastAccount() const { return m_account; }
+    QString lastNickname() const { return m_nickname; }
+    QString lastPassword() const { return m_password; }
+    bool rememberPassword() const { return m_rememberPassword; }
+    bool autoLogin() const { return m_autoLogin; }
+
+    void saveLogin(const QString &account, const QString &nickname,
+                   const QString &password, bool rememberPassword, bool autoLogin);
+
+    QString fontPreset() const { return m_fontPreset; }
+    void setFontPreset(const QString &key);
+
+    QRect windowRect() const { return m_windowRect; }
+    bool windowMaximized() const { return m_windowMaximized; }
+    void saveWindow(const QRect &rect, bool maximized);
+
+    // 0 未选择(首次关闭询问),1 最小化到托盘,2 退出程序
+    int closeAction() const { return m_closeAction; }
+    void setCloseAction(int action);
+
+private:
+    UserSettings() = default;
+    void persist() const;
+
+    QString m_path;
+    QString m_account;
+    QString m_nickname;
+    QString m_password;
+    bool m_rememberPassword = false;
+    bool m_autoLogin = false;
+    QString m_fontPreset = QStringLiteral("system");
+    QRect m_windowRect;
+    bool m_windowMaximized = false;
+    int m_closeAction = 0;
+};

+ 1 - 0
Client/src/contact/.gitkeep

@@ -0,0 +1 @@
+

+ 215 - 0
Client/src/mainframe/AddFriendDialog.cpp

@@ -0,0 +1,215 @@
+#include "AddFriendDialog.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+#include "ImClient.h"
+#include "Protocol.h"
+#include "AvatarCache.h"
+#include "common/protocol_def.h"
+
+#include <QJsonObject>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QPointer>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+AddFriendDialog::AddFriendDialog(QWidget *parent)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("添加好友"));
+    setFixedSize(400, 460);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(QStringLiteral("添加好友"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *page = new QWidget(this);
+    auto *lay = new QVBoxLayout(page);
+    lay->setContentsMargins(24, 18, 24, 22);
+    lay->setSpacing(10);
+
+    auto *row = new QHBoxLayout();
+    row->setSpacing(8);
+    m_keyword = UiHelpers::lineEdit(QStringLiteral("输入账号搜索"), page);
+    auto *search = UiHelpers::primaryButton(QStringLiteral("搜索"), page);
+    search->setFixedWidth(72);
+    search->setMinimumWidth(72);
+    row->addWidget(m_keyword, 1);
+    row->addWidget(search);
+
+    m_error = UiHelpers::errorLabel(page);
+
+    m_result = new QWidget(page);
+    m_result->setObjectName(QStringLiteral("FriendResult"));
+    m_result->setStyleSheet(QStringLiteral(
+        "QWidget#FriendResult { background:#F7F7F7; border:none; }"
+        "QLabel { background:transparent; border:none; }"));
+    auto *rLay = new QVBoxLayout(m_result);
+    rLay->setContentsMargins(12, 12, 12, 12);
+    rLay->setSpacing(8);
+
+    auto *top = new QHBoxLayout();
+    top->setSpacing(10);
+    m_avatar = new QLabel(m_result);
+    m_avatar->setFixedSize(44, 44);
+    m_avatar->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+    auto *info = new QVBoxLayout();
+    info->setSpacing(2);
+    info->setContentsMargins(0, 0, 0, 0);
+    m_name = new QLabel(m_result);
+    m_name->setStyleSheet(QStringLiteral(
+        "QLabel { font-size:14px; color:#1A1A1A; background:transparent; border:none; }"));
+    m_account = new QLabel(m_result);
+    m_account->setStyleSheet(QStringLiteral(
+        "QLabel { font-size:12px; color:#9A9A9A; background:transparent; border:none; }"));
+    info->addWidget(m_name);
+    info->addWidget(m_account);
+    m_addBtn = UiHelpers::primaryButton(QStringLiteral("添加"), m_result);
+    m_addBtn->setFixedSize(72, 32);
+    top->addWidget(m_avatar, 0, Qt::AlignTop);
+    top->addLayout(info, 1);
+    top->addWidget(m_addBtn, 0, Qt::AlignTop);
+
+    m_status = new QLabel(m_result);
+    m_status->setWordWrap(true);
+    m_status->setStyleSheet(QStringLiteral(
+        "QLabel { color:#FA5151; font-size:12px; background:transparent; border:none; }"));
+    m_status->hide();
+
+    m_verify = UiHelpers::lineEdit(QStringLiteral("验证信息(选填)"), m_result);
+    m_verify->setMaxLength(50);
+    m_verify->hide();
+
+    rLay->addLayout(top);
+    rLay->addWidget(m_status);
+    rLay->addWidget(m_verify);
+    m_result->hide();
+
+    lay->addLayout(row);
+    lay->addWidget(m_error);
+    lay->addWidget(m_result);
+    lay->addStretch();
+    root->addWidget(page, 1);
+
+    connect(search, &QPushButton::clicked, this, &AddFriendDialog::onSearch);
+    connect(m_keyword, &QLineEdit::returnPressed, this, &AddFriendDialog::onSearch);
+    connect(m_addBtn, &QPushButton::clicked, this, &AddFriendDialog::onAdd);
+    m_keyword->setFocus();
+}
+
+void AddFriendDialog::clearResult()
+{
+    m_uid = 0;
+    m_userAccount.clear();
+    m_result->hide();
+    m_status->hide();
+    m_status->clear();
+    m_verify->hide();
+    m_verify->clear();
+    m_addBtn->show();
+}
+
+void AddFriendDialog::showResult(const QJsonObject &user)
+{
+    m_uid = Proto::jsonI64(user, QStringLiteral("uid"));
+    m_userAccount = user.value(QStringLiteral("account")).toString();
+    QString name = user.value(QStringLiteral("nickname")).toString();
+    if (name.isEmpty())
+        name = m_userAccount;
+    m_name->setText(name);
+    m_account->setText(QStringLiteral("账号  %1").arg(m_userAccount));
+    const int ver = static_cast<int>(Proto::jsonI64(user, QStringLiteral("avatar_ver")));
+    AvatarCache::instance().applyTo(m_avatar, m_uid, ver, name, 44);
+
+    const int rel = user.value(QStringLiteral("relation")).toInt();
+    const int allowAdd = user.value(QStringLiteral("allow_add")).toInt(1);
+
+    QString status;
+    bool canAdd = false;
+    if (m_uid == ImClient::instance().uid())
+        status = QStringLiteral("这是你自己的账号");
+    else if (rel == im::FriendRel::Friend)
+        status = QStringLiteral("已是好友");
+    else if (rel == im::FriendRel::Pending)
+        status = QStringLiteral("已发送申请,等待对方处理");
+    else if (rel == im::FriendRel::Incoming)
+        status = QStringLiteral("对方已申请添加你,请到「新的朋友」处理");
+    else if (allowAdd == 0)
+        status = QStringLiteral("对方不允许添加好友");
+    else
+        canAdd = true;
+
+    m_addBtn->setVisible(canAdd);
+    m_addBtn->setEnabled(canAdd);
+    m_addBtn->setText(QStringLiteral("添加"));
+    m_verify->setVisible(canAdd);
+    if (canAdd)
+    {
+        m_status->hide();
+        m_status->clear();
+        m_verify->setFocus();
+    }
+    else
+    {
+        m_status->setText(status);
+        m_status->show();
+    }
+    m_result->show();
+}
+
+void AddFriendDialog::onSearch()
+{
+    const QString key = m_keyword->text().trimmed();
+    UiHelpers::setError(m_error, QString());
+    clearResult();
+    if (key.isEmpty())
+    {
+        UiHelpers::setError(m_error, QStringLiteral("请输入账号或手机号"));
+        return;
+    }
+    QPointer<AddFriendDialog> self(this);
+    ImClient::instance().searchUser(key, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+            return;
+        }
+        self->showResult(reply);
+    });
+}
+
+void AddFriendDialog::onAdd()
+{
+    if (m_uid <= 0)
+        return;
+    m_addBtn->setEnabled(false);
+    const QString message = m_verify->text().trimmed();
+    QPointer<AddFriendDialog> self(this);
+    ImClient::instance().addFriend(m_uid, message, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            self->m_addBtn->setEnabled(true);
+            UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+            return;
+        }
+        self->m_addBtn->hide();
+        self->m_verify->hide();
+        self->m_status->setText(QStringLiteral("已发送好友申请,等待对方同意"));
+        self->m_status->show();
+        UiHelpers::showToast(self, QStringLiteral("已发送好友申请,等待对方同意"));
+    });
+}

+ 35 - 0
Client/src/mainframe/AddFriendDialog.h

@@ -0,0 +1,35 @@
+#pragma once
+
+#include <QDialog>
+#include <QJsonObject>
+
+class QLineEdit;
+class QLabel;
+class QPushButton;
+class QWidget;
+
+class AddFriendDialog : public QDialog
+{
+    Q_OBJECT
+
+public:
+    explicit AddFriendDialog(QWidget *parent = nullptr);
+
+private:
+    void onSearch();
+    void onAdd();
+    void showResult(const QJsonObject &user);
+    void clearResult();
+
+    QLineEdit *m_keyword = nullptr;
+    QLabel *m_error = nullptr;
+    QWidget *m_result = nullptr;
+    QLabel *m_avatar = nullptr;
+    QLabel *m_name = nullptr;
+    QLabel *m_account = nullptr;
+    QPushButton *m_addBtn = nullptr;
+    QLabel *m_status = nullptr;
+    QLineEdit *m_verify = nullptr;
+    qint64 m_uid = 0;
+    QString m_userAccount;
+};

+ 3446 - 0
Client/src/mainframe/Client.cpp

@@ -0,0 +1,3446 @@
+#include "Client.h"
+#include "AddFriendDialog.h"
+#include "FriendRequestDialog.h"
+#include "ChangePasswordDialog.h"
+#include "SettingsDialog.h"
+#include "PickFriendDialog.h"
+#include "StickerCache.h"
+#include "AvatarCache.h"
+#include "FileCache.h"
+#include "FileTypeIcon.h"
+#include "ImagePreviewDialog.h"
+#include "StickerPanel.h"
+#include "UiHelpers.h"
+#include "AppTheme.h"
+#include "AppConfig.h"
+#include "TitleBar.h"
+#include "FramelessHelper.h"
+#include "ConfirmDialog.h"
+#include "PromptDialog.h"
+#include "UserSettings.h"
+#include "LocalStore.h"
+#include "ImClient.h"
+#include "Protocol.h"
+#include "Updater.h"
+
+#include <algorithm>
+
+#include <QAbstractItemView>
+#include <QAction>
+#include <QApplication>
+#include <QCloseEvent>
+#include <QCursor>
+#include <QDate>
+#include <QDateTime>
+#include <QDesktopServices>
+#include <QFile>
+#include <QFileDialog>
+#include <QFileInfo>
+#include <QMimeDatabase>
+#include <QStandardPaths>
+#include <QUrl>
+#include <QDialog>
+#include <QEvent>
+#include <QFontMetrics>
+#include <QShowEvent>
+#include <QFrame>
+#include <QImage>
+#include <QMouseEvent>
+#include <QGridLayout>
+#include <QHBoxLayout>
+#include <QIcon>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QKeyEvent>
+#include <QLabel>
+#include <QContextMenuEvent>
+#include <QLineEdit>
+#include <QListWidget>
+#include <QMap>
+#include <QMenu>
+#include <QObject>
+#include <QPainter>
+#include <QPainterPath>
+#include <QPalette>
+#include <QPen>
+#include <QPointer>
+#include <QProgressBar>
+#include <QPushButton>
+#include <QRect>
+#include <QResizeEvent>
+#include <QMenuBar>
+#include <QScrollArea>
+#include <QScrollBar>
+#include <QSizePolicy>
+#include <QSplitter>
+#include <QStackedWidget>
+#include <QStatusBar>
+#include <QSystemTrayIcon>
+#include <QTextEdit>
+#include <QTimer>
+#include <QVariant>
+#include <QtMath>
+#include <QVBoxLayout>
+
+#include <functional>
+
+namespace
+{
+    enum {
+        RolePeerUid = Qt::UserRole,
+        RoleName = Qt::UserRole + 1,
+        RoleUnread = Qt::UserRole + 2,
+        RolePreview = Qt::UserRole + 3,
+        RoleTs = Qt::UserRole + 4,
+        RoleAccount = Qt::UserRole + 5,
+        RoleSignature = Qt::UserRole + 6,
+        RoleRemark = Qt::UserRole + 7,
+        RoleNick = Qt::UserRole + 8,
+        RoleAvatarVer = Qt::UserRole + 9,
+        RoleOnline = Qt::UserRole + 10
+    };
+
+    constexpr int kReconnectMaxTries = 3;
+    constexpr int kReconnectIntervalSec = 10;
+
+    class SpinRing : public QWidget
+    {
+    public:
+        explicit SpinRing(QWidget *parent = nullptr)
+            : QWidget(parent)
+        {
+            setFixedSize(36, 36);
+            auto *t = new QTimer(this);
+            connect(t, &QTimer::timeout, this, [this]() {
+                m_angle = (m_angle + 30) % 360;
+                update();
+            });
+            t->start(80);
+        }
+
+    protected:
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            p.setRenderHint(QPainter::Antialiasing, true);
+            const QRectF r = QRectF(rect()).adjusted(3.5, 3.5, -3.5, -3.5);
+            QPen back(QColor(7, 193, 96, 40), 3, Qt::SolidLine, Qt::RoundCap);
+            p.setPen(back);
+            p.drawEllipse(r);
+            QPen pen(QColor("#07C160"), 3, Qt::SolidLine, Qt::RoundCap);
+            p.setPen(pen);
+            p.drawArc(r, -m_angle * 16, 220 * 16);
+        }
+
+    private:
+        int m_angle = 0;
+    };
+
+    struct Conv
+    {
+        qint64 uid = 0;
+        QString name;
+        QString preview;
+        QString time;
+        int unread = 0;
+        int avatarVer = 0;
+    };
+
+    QString formatTs(qint64 ms)
+    {
+        if (ms <= 0)
+            return QString();
+        const QDateTime dt = QDateTime::fromMSecsSinceEpoch(ms);
+        const QDate today = QDate::currentDate();
+        if (dt.date() == today)
+            return dt.toString(QStringLiteral("HH:mm"));
+        if (dt.date() == today.addDays(-1))
+            return QStringLiteral("昨天");
+        if (dt.date().year() == today.year())
+            return dt.toString(QStringLiteral("MM-dd"));
+        return dt.toString(QStringLiteral("yyyy-MM-dd"));
+    }
+
+    class ClickWidget : public QFrame
+    {
+    public:
+        std::function<void()> onClick;
+        std::function<void()> onForward;
+        std::function<void()> onSaveAs;
+        explicit ClickWidget(QWidget *parent = nullptr) : QFrame(parent)
+        {
+            setCursor(Qt::PointingHandCursor);
+        }
+
+    protected:
+        void mouseReleaseEvent(QMouseEvent *e) override
+        {
+            if (e->button() == Qt::LeftButton && onClick)
+                onClick();
+        }
+
+        void contextMenuEvent(QContextMenuEvent *e) override
+        {
+            if (!onForward && !onSaveAs)
+            {
+                QFrame::contextMenuEvent(e);
+                return;
+            }
+            // 不要把菜单挂在气泡上:气泡 stylesheet 会污染 QMenu,变成黑底看不清
+            QMenu menu;
+            QAction *fwd = onForward ? menu.addAction(QStringLiteral("转发")) : nullptr;
+            QAction *save = onSaveAs ? menu.addAction(QStringLiteral("另存为")) : nullptr;
+            QAction *picked = menu.exec(e->globalPos());
+            if (picked && picked == fwd && onForward)
+                onForward();
+            else if (picked && picked == save && onSaveAs)
+                onSaveAs();
+        }
+    };
+
+    QPixmap fileCtlIcon(bool pause, bool upload = false)
+    {
+        const int size = 22;
+        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);
+        const QColor fg(QStringLiteral("#576B95"));
+        p.setPen(QPen(fg, 1.4));
+        p.setBrush(Qt::NoBrush);
+        p.drawEllipse(QRectF(1.2, 1.2, size - 2.4, size - 2.4));
+        if (pause)
+        {
+            p.setPen(Qt::NoPen);
+            p.setBrush(fg);
+            p.drawRoundedRect(QRectF(7.2, 6.4, 2.6, 9.2), 0.8, 0.8);
+            p.drawRoundedRect(QRectF(12.2, 6.4, 2.6, 9.2), 0.8, 0.8);
+        }
+        else
+        {
+            p.setPen(QPen(fg, 1.6, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin));
+            if (upload)
+            {
+                p.drawLine(QPointF(11.0, 14.4), QPointF(11.0, 8.0));
+                p.drawLine(QPointF(7.8, 10.4), QPointF(11.0, 6.4));
+                p.drawLine(QPointF(14.2, 10.4), QPointF(11.0, 6.4));
+            }
+            else
+            {
+                p.drawLine(QPointF(11.0, 6.2), QPointF(11.0, 12.6));
+                p.drawLine(QPointF(7.8, 10.4), QPointF(11.0, 14.4));
+                p.drawLine(QPointF(14.2, 10.4), QPointF(11.0, 14.4));
+            }
+        }
+        return pm;
+    }
+
+    QPushButton *fileCtlBtn(QWidget *parent, bool pause, bool upload = false)
+    {
+        auto *btn = new QPushButton(parent);
+        btn->setFixedSize(22, 22);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setFocusPolicy(Qt::NoFocus);
+        btn->setFlat(true);
+        btn->setIcon(QIcon(fileCtlIcon(pause, upload)));
+        btn->setIconSize(QSize(22, 22));
+        btn->setToolTip(pause ? QStringLiteral("暂停")
+                              : (upload ? QStringLiteral("发送") : QStringLiteral("下载")));
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; padding:0; }"
+            "QPushButton:disabled { opacity:0.35; }"));
+        return btn;
+    }
+
+    class FileBubble : public ClickWidget
+    {
+    public:
+        FileBubble(QWidget *parent, qint64 fileId, const QString &name, qint64 size,
+                   const QString &url, const QString &mime, bool mine)
+            : ClickWidget(parent)
+            , m_fileId(fileId)
+            , m_size(size)
+            , m_url(url)
+            , m_mime(mime)
+            , m_name(name)
+            , m_mine(mine)
+        {
+            setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
+            setFixedWidth(252);
+
+            FileCache::instance().bindMeta(fileId, url, mime, name, size);
+
+            auto *root = new QVBoxLayout(this);
+            root->setContentsMargins(8, 8, 10, 8);
+            root->setSpacing(6);
+
+            auto *top = new QHBoxLayout();
+            top->setContentsMargins(0, 0, 0, 0);
+            top->setSpacing(8);
+
+            m_actions = new QWidget(this);
+            auto *actLay = new QVBoxLayout(m_actions);
+            actLay->setContentsMargins(0, 2, 0, 0);
+            actLay->setSpacing(4);
+            m_dl = fileCtlBtn(m_actions, false, mine);
+            m_pause = fileCtlBtn(m_actions, true);
+            actLay->addWidget(m_dl);
+            actLay->addWidget(m_pause);
+            actLay->addStretch();
+
+            auto *icon = new QLabel(this);
+            icon->setFixedSize(40, 40);
+            icon->setScaledContents(true);
+            icon->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            if (FileCache::looksLikeImage(mime, name))
+            {
+                const QPixmap pm = FileCache::instance().thumbnail(fileId, 40);
+                icon->setPixmap(pm.isNull() ? FileTypeIcon::pixmap(name, 40) : pm);
+            }
+            else
+            {
+                icon->setPixmap(FileTypeIcon::pixmap(name, 40));
+            }
+
+            auto *info = new QVBoxLayout();
+            info->setContentsMargins(0, 0, 0, 0);
+            info->setSpacing(2);
+            auto *n = new QLabel(name, this);
+            n->setWordWrap(true);
+            n->setStyleSheet(QStringLiteral(
+                "color:#191919; font-size:13px; background:transparent; border:none;"));
+            m_statusLab = new QLabel(this);
+            m_statusLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent; border:none;"));
+            info->addWidget(n);
+            info->addWidget(m_statusLab);
+            info->addStretch();
+
+            top->addWidget(m_actions, 0, Qt::AlignTop);
+            top->addWidget(icon, 0, Qt::AlignTop);
+            top->addLayout(info, 1);
+
+            m_bar = new QProgressBar(this);
+            m_bar->setRange(0, 1000);
+            m_bar->setValue(0);
+            m_bar->setTextVisible(false);
+            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);
+            meta->setSpacing(8);
+            m_sizeLab = new QLabel(this);
+            m_sizeLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent; border:none;"));
+            m_speedLab = new QLabel(this);
+            m_speedLab->setStyleSheet(QStringLiteral(
+                "color:#888888; font-size:11px; background:transparent; border:none;"));
+            meta->addWidget(m_sizeLab, 1);
+            meta->addWidget(m_speedLab, 0, Qt::AlignRight);
+
+            root->addLayout(top);
+            root->addWidget(m_bar);
+            root->addLayout(meta);
+
+            connect(m_dl, &QPushButton::clicked, this, [this]() {
+                if (m_mine)
+                    FileCache::instance().startUpload(m_fileId);
+                else
+                    FileCache::instance().startDownload(m_fileId, m_url, m_mime, m_name, m_size);
+            });
+            connect(m_pause, &QPushButton::clicked, this, [this]() {
+                if (m_mine)
+                    FileCache::instance().cancelUpload(m_fileId);
+                else
+                    FileCache::instance().cancelDownload(m_fileId);
+            });
+            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;
+                if (!m_mine)
+                {
+                    UiHelpers::showToast(window(),
+                                         error.isEmpty() ? QStringLiteral("下载失败") : error,
+                                         true);
+                }
+                syncUi();
+            });
+
+            onClick = [this]() {
+                const QString path = FileCache::instance().localPath(m_fileId);
+                if (path.isEmpty())
+                    return;
+                if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path)))
+                    UiHelpers::showToast(window(), QStringLiteral("无法打开文件"), true);
+            };
+            onSaveAs = [this]() {
+                const QString src = FileCache::instance().localPath(m_fileId);
+                if (src.isEmpty())
+                {
+                    UiHelpers::showToast(window(), QStringLiteral("请先下载文件"), true);
+                    return;
+                }
+                const QString dest = QFileDialog::getSaveFileName(window(), QStringLiteral("保存文件"), m_name);
+                if (dest.isEmpty())
+                    return;
+                QFile::remove(dest);
+                if (!QFile::copy(src, dest))
+                    UiHelpers::showToast(window(), QStringLiteral("保存失败"), true);
+                else
+                    UiHelpers::showToast(window(), QStringLiteral("已保存"));
+            };
+
+            syncUi();
+        }
+
+    private:
+        void syncUi()
+        {
+            const FileCache::Status st = FileCache::instance().status(m_fileId);
+            const bool pendingUp = m_mine && FileCache::instance().needsUpload(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) && !pendingUp;
+            const bool transferring = (st == FileCache::Downloading) || (st == FileCache::Uploading);
+            m_actions->setVisible(!received);
+            m_dl->setVisible(!transferring);
+            m_pause->setVisible(transferring);
+
+            QString status = FileCache::statusText(st);
+            if (pendingUp && st != FileCache::Uploading)
+                status = QStringLiteral("已暂停");
+            else if (st == FileCache::NotReceived && rec > 0)
+                status = QStringLiteral("已暂停");
+            if (m_mine && received)
+                status = QStringLiteral("已发送");
+            m_statusLab->setText(status);
+
+            m_bar->setVisible(!received);
+            if (tot > 0)
+                m_bar->setValue(static_cast<int>(qBound(qint64(0), rec * 1000 / tot, qint64(1000))));
+            else
+                m_bar->setValue(0);
+
+            if (received)
+            {
+                m_sizeLab->setText(FileCache::formatSize(tot > 0 ? tot : m_size));
+                m_speedLab->clear();
+            }
+            else
+            {
+                m_sizeLab->setText(QStringLiteral("%1 / %2")
+                                       .arg(FileCache::formatSize(rec),
+                                            tot > 0 ? FileCache::formatSize(tot) : QStringLiteral("--")));
+                if (transferring)
+                    m_speedLab->setText(FileCache::formatSpeed(bps));
+                else
+                    m_speedLab->setText(rec > 0 ? QStringLiteral("已暂停") : QString());
+            }
+        }
+
+        qint64 m_fileId = 0;
+        qint64 m_size = 0;
+        QString m_url;
+        QString m_mime;
+        QString m_name;
+        bool m_mine = false;
+        QWidget *m_actions = nullptr;
+        QPushButton *m_dl = nullptr;
+        QPushButton *m_pause = nullptr;
+        QLabel *m_statusLab = nullptr;
+        QProgressBar *m_bar = nullptr;
+        QLabel *m_sizeLab = nullptr;
+        QLabel *m_speedLab = nullptr;
+    };
+
+    class ElideLabel : public QLabel
+    {
+    public:
+        explicit ElideLabel(const QString &text, QWidget *parent = nullptr)
+            : QLabel(parent)
+            , m_full(text)
+        {
+            setMinimumWidth(0);
+            setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
+            setText(text);
+        }
+
+    protected:
+        void resizeEvent(QResizeEvent *event) override
+        {
+            QLabel::resizeEvent(event);
+            QLabel::setText(fontMetrics().elidedText(m_full, Qt::ElideRight, qMax(1, width())));
+        }
+
+    private:
+        QString m_full;
+    };
+
+    class TrayPreviewRow : public QWidget
+    {
+    public:
+        std::function<void()> onActivate;
+
+        explicit TrayPreviewRow(QWidget *parent = nullptr)
+            : QWidget(parent)
+        {
+            setAttribute(Qt::WA_Hover);
+            setCursor(Qt::PointingHandCursor);
+            setAutoFillBackground(true);
+            setMinimumHeight(48);
+            applyHover(false);
+        }
+
+    protected:
+        void enterEvent(QEvent *event) override
+        {
+            applyHover(true);
+            QWidget::enterEvent(event);
+        }
+
+        void leaveEvent(QEvent *event) override
+        {
+            applyHover(false);
+            QWidget::leaveEvent(event);
+        }
+
+        void mouseReleaseEvent(QMouseEvent *event) override
+        {
+            if (event->button() == Qt::LeftButton && rect().contains(event->pos()) && onActivate)
+                onActivate();
+            QWidget::mouseReleaseEvent(event);
+        }
+
+    private:
+        void applyHover(bool on)
+        {
+            QPalette pal = palette();
+            pal.setColor(QPalette::Window, on ? QColor("#F2F2F2") : QColor("#FFFFFF"));
+            setPalette(pal);
+        }
+    };
+
+    QPixmap navIcon(int kind, const QColor &color)
+    {
+        const int size = 22;
+        QPixmap pm(size, size);
+        pm.fill(Qt::transparent);
+        QPainter p(&pm);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.setPen(QPen(color, 1.6));
+        p.setBrush(Qt::NoBrush);
+        if (kind == 0)
+        {
+            p.drawRoundedRect(QRectF(2.5, 3.5, 17, 12.5), 2.5, 2.5);
+            QPainterPath tail;
+            tail.moveTo(6.5, 16);
+            tail.lineTo(8.5, 20);
+            tail.lineTo(12, 16);
+            p.setBrush(color);
+            p.setPen(Qt::NoPen);
+            p.drawPath(tail);
+        }
+        else if (kind == 1)
+        {
+            p.drawEllipse(QRectF(6.5, 3.2, 9, 9));
+            p.drawArc(QRect(3, 12, 16, 12), 20 * 16, 140 * 16);
+        }
+        else
+        {
+            const QPointF c(11, 11);
+            for (int i = 0; i < 6; ++i)
+            {
+                const qreal a = i * 3.14159265 / 3.0;
+                const QPointF d(qCos(a), qSin(a));
+                p.drawLine(c + d * 5.2, c + d * 8.6);
+            }
+            p.drawEllipse(QRectF(6.6, 6.6, 8.8, 8.8));
+            p.drawEllipse(QRectF(8.6, 8.6, 4.8, 4.8));
+        }
+        return pm;
+    }
+
+    QPixmap settingsNavIcon()
+    {
+        static QPixmap cached;
+        if (!cached.isNull())
+            return cached;
+        QPixmap src(QStringLiteral(":/icons/settings.png"));
+        if (src.isNull())
+        {
+            cached = navIcon(2, QColor("#A6A6A6"));
+            return cached;
+        }
+        QImage img = src.toImage().convertToFormat(QImage::Format_ARGB32);
+        for (int y = 0; y < img.height(); ++y)
+        {
+            auto *line = reinterpret_cast<QRgb *>(img.scanLine(y));
+            for (int x = 0; x < img.width(); ++x)
+            {
+                if (qRed(line[x]) < 40 && qGreen(line[x]) < 40 && qBlue(line[x]) < 40)
+                    line[x] = qRgba(0, 0, 0, 0);
+            }
+        }
+        cached = QPixmap::fromImage(img).scaled(22, 22, Qt::KeepAspectRatio, Qt::SmoothTransformation);
+        return cached;
+    }
+
+    QPushButton *navBtn(int kind, const QString &tip, QWidget *parent, bool checkable = true)
+    {
+        auto *btn = new QPushButton(parent);
+        btn->setCheckable(checkable);
+        btn->setFixedSize(56, 48);
+        btn->setCursor(Qt::PointingHandCursor);
+        btn->setToolTip(tip);
+        btn->setIconSize(QSize(22, 22));
+        btn->setIcon(QIcon(kind == 2 ? settingsNavIcon() : navIcon(kind, QColor("#A6A6A6"))));
+        btn->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; border-radius:0; padding:0; }"
+            "QPushButton:hover { background:#3A3A3A; }"
+            "QPushButton:checked { background:transparent; }"));
+        if (checkable)
+        {
+            QObject::connect(btn, &QPushButton::toggled, btn, [btn, kind](bool on) {
+                btn->setIcon(QIcon(navIcon(kind, on ? QColor("#07C160") : QColor("#A6A6A6"))));
+            });
+        }
+        return btn;
+    }
+
+    QWidget *makeFriendRow(const QString &name, bool online, qint64 uid, int avatarVer, QWidget *parent)
+    {
+        auto *row = new QWidget(parent);
+        row->setFixedHeight(48);
+        row->setAttribute(Qt::WA_TranslucentBackground);
+        row->setStyleSheet(QStringLiteral("background:transparent;"));
+
+        auto *lay = new QHBoxLayout(row);
+        lay->setContentsMargins(12, 8, 12, 8);
+        lay->setSpacing(10);
+
+        auto *avatar = new QLabel(row);
+        avatar->setFixedSize(32, 32);
+        avatar->setPixmap(AvatarCache::instance().pixmap(uid, avatarVer, name, 32));
+
+        auto *nameLab = new ElideLabel(name, row);
+        nameLab->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
+
+        auto *dot = new QLabel(row);
+        dot->setFixedSize(8, 8);
+        dot->setAttribute(Qt::WA_StyledBackground, true);
+        dot->setStyleSheet(online
+            ? QStringLiteral("background:#07C160; border:none; border-radius:4px;")
+            : QStringLiteral("background:#C8C8C8; border:none; border-radius:4px;"));
+        dot->setToolTip(online ? QStringLiteral("在线") : QStringLiteral("离线"));
+
+        lay->addWidget(avatar, 0, Qt::AlignVCenter);
+        lay->addWidget(nameLab, 1, Qt::AlignVCenter);
+        lay->addWidget(dot, 0, Qt::AlignVCenter);
+        return row;
+    }
+
+    QWidget *makeConvRow(const Conv &c, QWidget *parent)
+    {
+        auto *row = new QWidget(parent);
+        row->setFixedHeight(68);
+        row->setAttribute(Qt::WA_TranslucentBackground);
+        row->setStyleSheet(QStringLiteral("background:transparent;"));
+
+        auto *grid = new QGridLayout(row);
+        grid->setContentsMargins(12, 10, 12, 10);
+        grid->setHorizontalSpacing(10);
+        grid->setVerticalSpacing(3);
+        grid->setColumnStretch(1, 1);
+
+        auto *avatarBox = new QWidget(row);
+        avatarBox->setFixedSize(44, 42);
+        auto *avatar = new QLabel(avatarBox);
+        avatar->setGeometry(0, 1, 40, 40);
+        avatar->setPixmap(AvatarCache::instance().pixmap(c.uid, c.avatarVer, c.name, 40));
+        avatar->setScaledContents(false);
+        if (c.unread > 0)
+        {
+            auto *badge = new QLabel(c.unread > 99 ? QStringLiteral("99") : QString::number(c.unread), avatarBox);
+            badge->setObjectName(QStringLiteral("unreadBadge"));
+            badge->setAlignment(Qt::AlignCenter);
+            badge->setFixedSize(16, 16);
+            badge->move(28, 0);
+            badge->setStyleSheet(QStringLiteral(
+                "background:#FA5151; color:#FFFFFF; font-size:10px; border:none; border-radius:8px;"));
+        }
+
+        auto *name = new ElideLabel(c.name, row);
+        name->setStyleSheet(QStringLiteral("color:#191919; font-size:14px; background:transparent;"));
+
+        auto *preview = new ElideLabel(c.preview, row);
+        preview->setStyleSheet(QStringLiteral("color:#9A9A9A; font-size:12px; background:transparent;"));
+
+        auto *time = new QLabel(c.time, row);
+        time->setStyleSheet(QStringLiteral("color:#B2B2B2; font-size:11px; background:transparent;"));
+        time->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
+        time->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred);
+
+        grid->addWidget(avatarBox, 0, 0, 2, 1, Qt::AlignVCenter);
+        grid->addWidget(name, 0, 1);
+        grid->addWidget(time, 0, 2, Qt::AlignRight | Qt::AlignVCenter);
+        grid->addWidget(preview, 1, 1, 1, 2);
+        return row;
+    }
+}
+
+Client::Client(const QString &account, const QString &nickname, QWidget *parent)
+    : QMainWindow(parent)
+    , m_account(account.isEmpty() ? ImClient::instance().account() : account)
+    , m_nickname(nickname.isEmpty() ? ImClient::instance().nickname() : nickname)
+    , m_uid(ImClient::instance().uid())
+{
+    if (m_nickname.isEmpty())
+        m_nickname = m_account;
+    setWindowTitle(QStringLiteral("NIM"));
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setMinimumSize(760, 500);
+    resize(1000, 712);
+    setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
+    setWindowIcon(UiHelpers::appIcon());
+    if (statusBar())
+        statusBar()->hide();
+    if (menuBar())
+        menuBar()->hide();
+    LocalStore::instance().openForUser(m_uid);
+    buildUi();
+    QTimer::singleShot(400, this, [this]() { Updater::promptIfNeeded(this); });
+}
+
+Client::~Client()
+{
+    LocalStore::instance().close();
+}
+
+void Client::buildUi()
+{
+    connect(&AvatarCache::instance(), &AvatarCache::updated, this, &Client::refreshAvatar);
+
+    auto *shell = new QWidget(this);
+    auto *root = new QVBoxLayout(shell);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    m_titleBar = new TitleBar(shell);
+    m_titleBar->setTitle(QStringLiteral("NIM"));
+    m_titleBar->setTheme(TitleBar::Light);
+    m_titleBar->setMaximizable(true);
+    m_titleBar->bind(this, true);
+    if (auto *helper = m_titleBar->framelessHelper())
+    {
+        m_geomSave = new QTimer(this);
+        m_geomSave->setSingleShot(true);
+        m_geomSave->setInterval(400);
+        connect(m_geomSave, &QTimer::timeout, this, &Client::persistWindow);
+        connect(helper, &FramelessHelper::normalGeometryChanged, this, [this]() {
+            if (m_geomSave)
+                m_geomSave->start();
+        });
+    }
+    root->addWidget(m_titleBar);
+
+    auto *body = new QWidget(shell);
+    auto *bodyLay = new QHBoxLayout(body);
+    bodyLay->setContentsMargins(0, 0, 0, 0);
+    bodyLay->setSpacing(0);
+    bodyLay->addWidget(buildNav());
+
+    auto *split = new QSplitter(Qt::Horizontal, body);
+    split->setHandleWidth(1);
+    split->setChildrenCollapsible(false);
+    split->addWidget(buildConversationPane());
+    split->addWidget(buildChatPane());
+    split->setStretchFactor(0, 0);
+    split->setStretchFactor(1, 1);
+    split->setSizes({260, 740});
+
+    bodyLay->addWidget(split, 1);
+
+    auto *bodyWrap = new QWidget(shell);
+    auto *wrapLay = new QGridLayout(bodyWrap);
+    wrapLay->setContentsMargins(0, 0, 0, 0);
+    wrapLay->setSpacing(0);
+    wrapLay->addWidget(body, 0, 0);
+    setupReconnectOverlay(bodyWrap);
+    wrapLay->addWidget(m_reconnectMask, 0, 0);
+    root->addWidget(bodyWrap, 1);
+    setCentralWidget(shell);
+
+    connect(&ImClient::instance(), &ImClient::chatNotify, this, &Client::onChatNotify);
+    connect(&ImClient::instance(), &ImClient::friendRequestNotify, this, &Client::onFriendRequestNotify);
+    connect(&ImClient::instance(), &ImClient::friendAcceptedNotify, this, &Client::onFriendAcceptedNotify);
+    connect(&ImClient::instance(), &ImClient::kicked, this, &Client::onKicked);
+    connect(&ImClient::instance(), &ImClient::disconnected, this, &Client::onDisconnected);
+    connect(&ImClient::instance(), &ImClient::connected, this, &Client::onConnected);
+    connect(&ImClient::instance(), &ImClient::connectFailed, this, &Client::onConnectFailed);
+
+    StickerCache::instance().sync();
+    setupTray();
+    loadLocalConvs();
+    refreshConvList();
+    refreshFriendList();
+    refreshAvatar(m_uid);
+}
+
+QWidget *Client::buildNav()
+{
+    auto *nav = new QWidget(this);
+    nav->setFixedWidth(56);
+    nav->setStyleSheet(QStringLiteral("background:#2E2E2E;"));
+
+    auto *lay = new QVBoxLayout(nav);
+    lay->setContentsMargins(0, 16, 0, 12);
+    lay->setSpacing(4);
+
+    auto *avatar = new QLabel(nav);
+    avatar->setFixedSize(36, 36);
+    avatar->setCursor(Qt::PointingHandCursor);
+    avatar->setToolTip(QStringLiteral("编辑资料"));
+    AvatarCache::instance().applyTo(
+        avatar, m_uid, ImClient::instance().avatarVer(), m_nickname, 36);
+    avatar->installEventFilter(this);
+    m_navAvatar = avatar;
+
+    m_navChat = navBtn(0, QStringLiteral("消息"), nav);
+    m_navFriends = navBtn(1, QStringLiteral("好友"), nav);
+    m_navSettings = navBtn(2, QStringLiteral("设置"), nav, false);
+    m_navChat->setChecked(true);
+
+    lay->addWidget(avatar, 0, Qt::AlignHCenter);
+    lay->addSpacing(18);
+    lay->addWidget(m_navChat, 0, Qt::AlignHCenter);
+    lay->addWidget(m_navFriends, 0, Qt::AlignHCenter);
+    lay->addStretch();
+    lay->addWidget(m_navSettings, 0, Qt::AlignHCenter);
+
+    connect(m_navChat, &QPushButton::clicked, this, [this]() { applyNavSelection(0); });
+    connect(m_navFriends, &QPushButton::clicked, this, [this]() { applyNavSelection(1); });
+    connect(m_navSettings, &QPushButton::clicked, this, [this]() { showSettings(1); });
+    return nav;
+}
+
+QWidget *Client::buildConversationPane()
+{
+    auto *pane = new QWidget(this);
+    pane->setMinimumWidth(220);
+    pane->setMaximumWidth(360);
+    pane->setStyleSheet(QStringLiteral("background:#F5F5F5;"));
+
+    auto *lay = new QVBoxLayout(pane);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
+
+    auto *searchWrap = new QWidget(pane);
+    searchWrap->setFixedHeight(56);
+    searchWrap->setStyleSheet(QStringLiteral("background:#F5F5F5; border-bottom:1px solid #E7E7E7;"));
+    auto *sLay = new QHBoxLayout(searchWrap);
+    sLay->setContentsMargins(10, 12, 10, 12);
+    sLay->setSpacing(6);
+    m_search = new QLineEdit(searchWrap);
+    m_search->setPlaceholderText(QStringLiteral("搜索"));
+    m_search->setFixedHeight(28);
+    m_search->setStyleSheet(QStringLiteral(
+        "QLineEdit { background:#FFFFFF; border:1px solid #E5E5E5; padding:3px 8px; border-radius:2px; }"));
+    m_addFriendBtn = new QPushButton(QStringLiteral("+"), searchWrap);
+    m_addFriendBtn->setFixedSize(28, 28);
+    m_addFriendBtn->setCursor(Qt::PointingHandCursor);
+    m_addFriendBtn->setToolTip(QStringLiteral("添加好友"));
+    m_addFriendBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:1px solid #E5E5E5; color:#07C160; font-size:18px; border-radius:2px; }"
+        "QPushButton:hover { background:#F5F5F5; }"));
+    m_addFriendBtn->hide();
+    sLay->addWidget(m_search, 1);
+    sLay->addWidget(m_addFriendBtn);
+
+    m_sideStack = new QStackedWidget(pane);
+
+    auto makeEmpty = [](QWidget *parent, const QString &text) {
+        auto *lab = new QLabel(text, parent);
+        lab->setAlignment(Qt::AlignCenter);
+        lab->setWordWrap(true);
+        lab->setStyleSheet(QStringLiteral(
+            "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
+        return lab;
+    };
+
+    m_convStack = new QStackedWidget(m_sideStack);
+    m_convList = new QListWidget(m_convStack);
+    m_convList->setFrameShape(QFrame::NoFrame);
+    m_convList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    m_convList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+    m_convList->setFocusPolicy(Qt::NoFocus);
+    m_convList->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#F5F5F5; border:none; outline:0; color:transparent; }"
+        "QListWidget::item { height:68px; padding:0; margin:0; border:none; color:transparent; }"
+        "QListWidget::item:hover { background:#EDEDED; }"
+        "QListWidget::item:selected { background:#C6C6C6; }"
+        "QListWidget::item:selected:hover { background:#C6C6C6; }"));
+    m_convList->setContextMenuPolicy(Qt::CustomContextMenu);
+
+    m_convEmpty = makeEmpty(m_convStack, QStringLiteral("暂无消息"));
+    m_convStack->addWidget(m_convList);
+    m_convStack->addWidget(m_convEmpty);
+
+    m_sideStack->addWidget(m_convStack);
+    m_sideStack->addWidget(buildFriendPane());
+
+    lay->addWidget(searchWrap);
+    lay->addWidget(m_sideStack, 1);
+
+    connect(m_convList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
+        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
+            return;
+        const int row = m_convList->row(item);
+        const qint64 uid = item->data(RolePeerUid).toLongLong();
+        if (row == m_convList->currentRow() && m_currentPeerUid == uid
+            && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
+            return;
+        selectConversation(row);
+    });
+    connect(m_convList, &QListWidget::customContextMenuRequested, this, &Client::onConvMenu);
+    connect(m_addFriendBtn, &QPushButton::clicked, this, &Client::openAddFriend);
+    connect(m_search, &QLineEdit::textChanged, this, [this](const QString &) { applyListFilter(); });
+    applyListFilter();
+    return pane;
+}
+
+QWidget *Client::buildFriendPane()
+{
+    auto *page = new QWidget(m_sideStack);
+    auto *lay = new QVBoxLayout(page);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(0);
+
+    m_newFriendsBtn = new QPushButton(page);
+    m_newFriendsBtn->setCursor(Qt::PointingHandCursor);
+    m_newFriendsBtn->setFixedHeight(52);
+    m_newFriendsBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#F5F5F5; border:none; border-bottom:1px solid #E7E7E7; text-align:left; padding-left:12px; color:#1A1A1A; }"
+        "QPushButton:hover { background:#EDEDED; }"));
+    auto *barLay = new QHBoxLayout(m_newFriendsBtn);
+    barLay->setContentsMargins(12, 0, 12, 0);
+    auto *title = new QLabel(QStringLiteral("新的朋友"), m_newFriendsBtn);
+    title->setStyleSheet(QStringLiteral("background:transparent; font-size:14px;"));
+    title->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+    m_pendingBadge = new QLabel(m_newFriendsBtn);
+    m_pendingBadge->setObjectName(QStringLiteral("unreadBadge"));
+    m_pendingBadge->setAlignment(Qt::AlignCenter);
+    m_pendingBadge->setFixedSize(18, 18);
+    m_pendingBadge->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
+    m_pendingBadge->setStyleSheet(QStringLiteral(
+        "QLabel { background:#FA5151; color:#FFFFFF; font-size:10px; border:none; border-radius:9px;"
+        " min-width:18px; max-width:18px; min-height:18px; max-height:18px; }"));
+    m_pendingBadge->hide();
+    barLay->setAlignment(Qt::AlignVCenter);
+    barLay->addWidget(title, 1, Qt::AlignVCenter);
+    barLay->addWidget(m_pendingBadge, 0, Qt::AlignVCenter | Qt::AlignRight);
+    connect(m_newFriendsBtn, &QPushButton::clicked, this, &Client::openFriendRequests);
+
+    m_friendStack = new QStackedWidget(page);
+    m_friendList = new QListWidget(m_friendStack);
+    m_friendList->setFrameShape(QFrame::NoFrame);
+    m_friendList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+    m_friendList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
+    m_friendList->setFocusPolicy(Qt::NoFocus);
+    m_friendList->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#F5F5F5; border:none; outline:0; color:transparent; }"
+        "QListWidget::item { height:48px; padding:0; margin:0; border:none; color:transparent; }"
+        "QListWidget::item:hover { background:#EDEDED; }"
+        "QListWidget::item:selected { background:#C6C6C6; }"));
+    m_friendList->setContextMenuPolicy(Qt::CustomContextMenu);
+    m_friendEmpty = new QLabel(QStringLiteral("暂无好友"), m_friendStack);
+    m_friendEmpty->setAlignment(Qt::AlignCenter);
+    m_friendEmpty->setWordWrap(true);
+    m_friendEmpty->setStyleSheet(QStringLiteral(
+        "color:#B0B0B0; font-size:13px; background:#F5F5F5; padding:24px;"));
+    m_friendStack->addWidget(m_friendList);
+    m_friendStack->addWidget(m_friendEmpty);
+
+    lay->addWidget(m_newFriendsBtn);
+    lay->addWidget(m_friendStack, 1);
+
+    connect(m_friendList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
+        if (!item || (QApplication::mouseButtons() & Qt::RightButton))
+            return;
+        showFriendCard(item->data(RolePeerUid).toLongLong());
+    });
+    connect(m_friendList, &QListWidget::customContextMenuRequested, this, &Client::onFriendMenu);
+    return page;
+}
+
+void Client::applyNavSelection(int index)
+{
+    m_navChat->setChecked(index == 0);
+    m_navFriends->setChecked(index == 1);
+    m_sideStack->setCurrentIndex(index);
+    m_search->setPlaceholderText(index == 1 ? QStringLiteral("搜索好友") : QStringLiteral("搜索"));
+    m_addFriendBtn->setVisible(index == 1);
+    m_search->clear();
+    applyListFilter();
+    if (index == 0 && m_chatStack)
+    {
+        if (m_currentPeerUid > 0 && m_chatBody)
+            m_chatStack->setCurrentWidget(m_chatBody);
+        else if (m_chatEmpty)
+            m_chatStack->setCurrentWidget(m_chatEmpty);
+    }
+    if (index == 1)
+        refreshFriendList();
+}
+
+QWidget *Client::buildChatPane()
+{
+    auto *pane = new QWidget(this);
+    pane->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    m_chatStack = new QStackedWidget(pane);
+    auto *outer = new QVBoxLayout(pane);
+    outer->setContentsMargins(0, 0, 0, 0);
+    outer->addWidget(m_chatStack);
+
+    m_chatEmpty = new QWidget(m_chatStack);
+    m_chatEmpty->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *emptyLay = new QVBoxLayout(m_chatEmpty);
+    auto *hint = new QLabel(QStringLiteral("选择一个会话开始聊天"), m_chatEmpty);
+    hint->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:14px; background:transparent;"));
+    hint->setAlignment(Qt::AlignCenter);
+    emptyLay->addStretch();
+    emptyLay->addWidget(hint);
+    emptyLay->addStretch();
+
+    m_chatBody = new QWidget(m_chatStack);
+    auto *body = new QVBoxLayout(m_chatBody);
+    body->setContentsMargins(0, 0, 0, 0);
+    body->setSpacing(0);
+
+    auto *header = new QWidget(m_chatBody);
+    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_chatTitle = new QLabel(header);
+    m_chatTitle->setCursor(Qt::PointingHandCursor);
+    m_chatTitle->setToolTip(QStringLiteral("查看名片"));
+    m_chatTitle->setStyleSheet(QStringLiteral("font-size:15px; font-weight:bold; background:transparent;"));
+    m_chatTitle->installEventFilter(this);
+    hLay->addWidget(m_chatTitle);
+    hLay->addStretch();
+
+    m_scroll = new QScrollArea(m_chatBody);
+    m_scroll->setWidgetResizable(true);
+    m_scroll->setFrameShape(QFrame::NoFrame);
+    m_scroll->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    m_bubbleHost = new QWidget(m_scroll);
+    m_bubbleHost->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    m_bubbleLay = new QVBoxLayout(m_bubbleHost);
+    m_bubbleLay->setContentsMargins(16, 16, 16, 16);
+    m_bubbleLay->setSpacing(12);
+    m_bubbleLay->addStretch();
+    m_scroll->setWidget(m_bubbleHost);
+
+    connect(m_scroll->verticalScrollBar(), &QScrollBar::rangeChanged, this, [this](int, int max) {
+        if (m_stickChatToBottom)
+            m_scroll->verticalScrollBar()->setValue(max);
+    });
+    connect(m_scroll->verticalScrollBar(), &QScrollBar::valueChanged, this, [this](int v) {
+        if (!m_scroll)
+            return;
+        const int max = m_scroll->verticalScrollBar()->maximum();
+        m_stickChatToBottom = (max - v) <= 24;
+    });
+
+    auto *inputWrap = new QWidget(m_chatBody);
+    inputWrap->setFixedHeight(148);
+    inputWrap->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
+    auto *iLay = new QVBoxLayout(inputWrap);
+    iLay->setContentsMargins(0, 0, 0, 0);
+    iLay->setSpacing(0);
+
+    auto *toolbar = new QWidget(inputWrap);
+    toolbar->setFixedHeight(36);
+    toolbar->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *tLay = new QHBoxLayout(toolbar);
+    tLay->setContentsMargins(12, 0, 12, 0);
+    auto *emoji = new QPushButton(QStringLiteral("表情"), toolbar);
+    emoji->setCursor(Qt::PointingHandCursor);
+    emoji->setFlat(true);
+    const QString toolQss = QStringLiteral(
+        "QPushButton { color:#07C160; font-size:12px; background:transparent; border:none; padding:0 8px; }"
+        "QPushButton:hover { color:#06AD56; }");
+    emoji->setStyleSheet(toolQss);
+    auto *fileBtn = new QPushButton(QStringLiteral("文件"), toolbar);
+    fileBtn->setCursor(Qt::PointingHandCursor);
+    fileBtn->setFlat(true);
+    fileBtn->setStyleSheet(toolQss);
+    auto *imgBtn = new QPushButton(QStringLiteral("图片"), toolbar);
+    imgBtn->setCursor(Qt::PointingHandCursor);
+    imgBtn->setFlat(true);
+    imgBtn->setStyleSheet(toolQss);
+    auto *tip = new QLabel(QStringLiteral("Enter 发送,Shift+Enter 换行"), toolbar);
+    tip->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:11px; background:transparent;"));
+    tLay->addWidget(emoji);
+    tLay->addWidget(fileBtn);
+    tLay->addWidget(imgBtn);
+    tLay->addWidget(tip);
+    tLay->addStretch();
+
+    m_input = new QTextEdit(inputWrap);
+    m_input->setPlaceholderText(QStringLiteral("输入消息"));
+    m_input->setAcceptRichText(false);
+    m_input->installEventFilter(this);
+
+    auto *sendRow = new QHBoxLayout();
+    sendRow->setContentsMargins(12, 0, 12, 10);
+    auto *send = UiHelpers::primaryButton(QStringLiteral("发送"), inputWrap);
+    send->setFixedSize(88, 32);
+    send->setStyleSheet(QStringLiteral(
+        "QPushButton { background-color:#07C160; color:#FFFFFF; border:1px solid #07C160; font-size:13px; border-radius:2px; }"
+        "QPushButton:hover { background-color:#06AD56; border-color:#06AD56; }"
+        "QPushButton:pressed { background-color:#038D4A; border-color:#038D4A; }"));
+    sendRow->addStretch();
+    sendRow->addWidget(send);
+
+    iLay->addWidget(toolbar);
+    iLay->addWidget(m_input, 1);
+    iLay->addLayout(sendRow);
+
+    body->addWidget(header);
+    body->addWidget(m_scroll, 1);
+    body->addWidget(inputWrap);
+
+    m_chatStack->addWidget(m_chatEmpty);
+    m_chatStack->addWidget(m_chatBody);
+
+    m_profilePage = new QWidget(m_chatStack);
+    m_profilePage->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *pLay = new QVBoxLayout(m_profilePage);
+    pLay->setContentsMargins(40, 48, 40, 32);
+    pLay->setSpacing(10);
+    pLay->setAlignment(Qt::AlignHCenter | Qt::AlignTop);
+    m_profileAvatar = new QLabel(m_profilePage);
+    m_profileAvatar->setFixedSize(72, 72);
+    m_profileTitle = new QLabel(m_profilePage);
+    m_profileTitle->setAlignment(Qt::AlignCenter);
+    m_profileTitle->setStyleSheet(QStringLiteral("font-size:18px; color:#1A1A1A; background:transparent; border:none;"));
+    auto metaStyle = QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent; border:none;");
+    m_profileRemark = new QLabel(m_profilePage);
+    m_profileRemark->setAlignment(Qt::AlignCenter);
+    m_profileRemark->setStyleSheet(metaStyle);
+    m_profileNick = new QLabel(m_profilePage);
+    m_profileNick->setAlignment(Qt::AlignCenter);
+    m_profileNick->setStyleSheet(metaStyle);
+    m_profileAccount = new QLabel(m_profilePage);
+    m_profileAccount->setAlignment(Qt::AlignCenter);
+    m_profileAccount->setStyleSheet(metaStyle);
+    m_profileSign = new QLabel(m_profilePage);
+    m_profileSign->setAlignment(Qt::AlignCenter);
+    m_profileSign->setWordWrap(true);
+    m_profileSign->setStyleSheet(QStringLiteral("font-size:13px; color:#8A8A8A; background:transparent; border:none;"));
+    auto *sendMsg = UiHelpers::compactButton(QStringLiteral("发消息"), m_profilePage, true);
+    sendMsg->setFixedWidth(120);
+    auto *shareMsg = UiHelpers::compactButton(QStringLiteral("分享"), m_profilePage);
+    shareMsg->setFixedWidth(120);
+    pLay->addWidget(m_profileAvatar, 0, Qt::AlignHCenter);
+    pLay->addSpacing(8);
+    pLay->addWidget(m_profileTitle);
+    pLay->addWidget(m_profileRemark);
+    pLay->addWidget(m_profileNick);
+    pLay->addWidget(m_profileAccount);
+    pLay->addWidget(m_profileSign);
+    pLay->addSpacing(16);
+    pLay->addWidget(sendMsg, 0, Qt::AlignHCenter);
+    pLay->addWidget(shareMsg, 0, Qt::AlignHCenter);
+    pLay->addStretch();
+    m_chatStack->addWidget(m_profilePage);
+    m_profileChatBtn = sendMsg;
+    m_profileShareBtn = shareMsg;
+    connect(sendMsg, &QPushButton::clicked, this, [this]() {
+        if (m_profileUid > 0)
+            openChat(m_profileUid, m_profileDisplayName);
+    });
+    connect(shareMsg, &QPushButton::clicked, this, [this]() {
+        shareUserCard(m_profileUser);
+    });
+
+    m_chatStack->setCurrentWidget(m_chatEmpty);
+
+    m_stickerPanel = new StickerPanel(m_chatBody);
+    m_stickerPanel->hide();
+    connect(emoji, &QPushButton::clicked, this, [this, inputWrap]() {
+        if (m_stickerPanel->isVisible())
+        {
+            m_stickerPanel->hide();
+            return;
+        }
+        const QPoint pos = inputWrap->mapTo(m_chatBody, QPoint(8, -m_stickerPanel->height() + 8));
+        m_stickerPanel->move(pos);
+        m_stickerPanel->raise();
+        m_stickerPanel->show();
+    });
+    connect(m_stickerPanel, &StickerPanel::stickerPicked, this, [this](qint64 packId, const QString &code) {
+        m_stickerPanel->hide();
+        sendSticker(packId, code);
+    });
+    connect(fileBtn, &QPushButton::clicked, this, [this]() { sendFile(false); });
+    connect(imgBtn, &QPushButton::clicked, this, [this]() { sendFile(true); });
+
+    connect(send, &QPushButton::clicked, this, &Client::sendCurrentMessage);
+    return pane;
+}
+
+void Client::refreshConvList()
+{
+    QPointer<Client> self(this);
+    ImClient::instance().convList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+            return;
+        self->applyConvList(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void Client::refreshFriendList()
+{
+    QPointer<Client> self(this);
+    ImClient::instance().friendList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+            return;
+        self->setPendingCount(reply.value(QStringLiteral("pending")).toInt());
+        self->applyFriendList(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void Client::applyConvList(const QJsonArray &list)
+{
+    const qint64 keep = m_currentPeerUid;
+    m_convList->clear();
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        Conv c;
+        c.uid = Proto::jsonI64(o, QStringLiteral("peer_uid"));
+        c.name = o.value(QStringLiteral("nickname")).toString();
+        if (c.name.isEmpty())
+            c.name = o.value(QStringLiteral("account")).toString();
+        c.preview = o.value(QStringLiteral("preview")).toString();
+        c.time = formatTs(Proto::jsonI64(o, QStringLiteral("ts")));
+        c.unread = o.value(QStringLiteral("unread")).toInt();
+        c.avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *item = new QListWidgetItem(m_convList);
+        item->setData(RolePeerUid, c.uid);
+        item->setData(RoleName, c.name);
+        item->setData(RoleUnread, c.unread);
+        item->setData(RolePreview, c.preview);
+        item->setData(RoleTs, Proto::jsonI64(o, QStringLiteral("ts")));
+        item->setData(RoleAvatarVer, c.avatarVer);
+        item->setSizeHint(QSize(200, 68));
+        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+        persistLocalConv(c.uid, c.name, c.preview, Proto::jsonI64(o, QStringLiteral("ts")), c.unread);
+    }
+    applyListFilter();
+    if (keep > 0)
+    {
+        if (auto *item = findConvItem(keep))
+        {
+            m_convList->blockSignals(true);
+            m_convList->setCurrentItem(item);
+            m_convList->blockSignals(false);
+        }
+    }
+    updateTray();
+}
+
+void Client::applyFriendList(const QJsonArray &list)
+{
+    m_friendList->clear();
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+        const QString account = o.value(QStringLiteral("account")).toString();
+        const QString nick = o.value(QStringLiteral("nickname")).toString();
+        const QString remark = o.value(QStringLiteral("remark")).toString();
+        QString name = remark.isEmpty() ? nick : remark;
+        if (name.isEmpty())
+            name = account;
+        const bool online = o.value(QStringLiteral("online")).toInt() == 1;
+        const int avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *item = new QListWidgetItem(m_friendList);
+        item->setData(RolePeerUid, uid);
+        item->setData(RoleName, name);
+        item->setData(RoleNick, nick);
+        item->setData(RoleAccount, account);
+        item->setData(RoleSignature, o.value(QStringLiteral("signature")).toString());
+        item->setData(RoleRemark, remark);
+        item->setData(RoleAvatarVer, avatarVer);
+        item->setData(RoleOnline, online ? 1 : 0);
+        item->setSizeHint(QSize(200, 48));
+        m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, avatarVer, m_friendList));
+        if (avatarVer > 0)
+        {
+            if (auto *conv = findConvItem(uid))
+            {
+                if (conv->data(RoleAvatarVer).toInt() != avatarVer)
+                {
+                    conv->setData(RoleAvatarVer, avatarVer);
+                    Conv c;
+                    c.uid = uid;
+                    c.name = conv->data(RoleName).toString();
+                    c.preview = conv->data(RolePreview).toString();
+                    c.time = formatTs(conv->data(RoleTs).toLongLong());
+                    c.unread = conv->data(RoleUnread).toInt();
+                    c.avatarVer = avatarVer;
+                    if (QWidget *old = m_convList->itemWidget(conv))
+                        old->deleteLater();
+                    m_convList->setItemWidget(conv, makeConvRow(c, m_convList));
+                }
+            }
+        }
+    }
+    applyListFilter();
+}
+
+void Client::applyHistory(const QJsonArray &list)
+{
+    QMap<qint64, LocalStore::Msg> byId;
+    int fallbackId = 0;
+    for (const auto &m : LocalStore::instance().listMessages(m_currentPeerUid))
+    {
+        if (m.id != 0)
+            byId.insert(m.id, m);
+    }
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        const qint64 from = Proto::jsonI64(o, QStringLiteral("from_uid"));
+        const qint64 to = Proto::jsonI64(o, QStringLiteral("to_uid"));
+        qint64 msgId = Proto::jsonI64(o, QStringLiteral("msg_id"));
+        if (msgId <= 0)
+            msgId = Proto::jsonI64(o, QStringLiteral("id"));
+        const qint64 peer = m_currentPeerUid > 0
+            ? m_currentPeerUid
+            : ((from == m_uid) ? to : from);
+        const int msgType = static_cast<int>(Proto::jsonI64(o, QStringLiteral("msg_type")));
+        const QString text = o.value(QStringLiteral("content")).toString();
+        const qint64 ts = Proto::jsonI64(o, QStringLiteral("ts"));
+        saveLocalMessage(msgId, peer, from, to, msgType > 0 ? msgType : 1, text, ts);
+
+        LocalStore::Msg m;
+        m.id = msgId > 0 ? msgId : --fallbackId;
+        m.peerUid = peer;
+        m.fromUid = from;
+        m.toUid = to;
+        m.msgType = msgType > 0 ? msgType : 1;
+        m.content = text;
+        m.ts = ts;
+        byId.insert(m.id, m);
+    }
+
+    QVector<LocalStore::Msg> rows;
+    rows.reserve(byId.size());
+    for (auto it = byId.constBegin(); it != byId.constEnd(); ++it)
+        rows.push_back(it.value());
+    std::sort(rows.begin(), rows.end(), [](const LocalStore::Msg &a, const LocalStore::Msg &b) {
+        if (a.ts != b.ts)
+            return a.ts < b.ts;
+        return a.id < b.id;
+    });
+
+    clearBubbles();
+    qint64 lastTs = 0;
+    for (const LocalStore::Msg &m : rows)
+    {
+        const bool mine = (m.fromUid == m_uid);
+        const QString name = mine ? m_nickname : m_currentPeerName;
+        QString time;
+        if (lastTs == 0 || qAbs(m.ts - lastTs) > 5 * 60 * 1000)
+            time = formatTs(m.ts);
+        lastTs = m.ts;
+        int status = m.status;
+        if (status == LocalMsg::Sending && !m_inflightLocalIds.contains(m.id))
+        {
+            status = LocalMsg::Failed;
+            LocalStore::Msg stale = m;
+            stale.status = LocalMsg::Failed;
+            LocalStore::instance().upsertMessage(stale);
+        }
+        addBubble(mine, name, m.content, time, m.msgType, status, m.id, m.peerUid);
+    }
+    scrollChatToBottom();
+}
+
+QListWidgetItem *Client::findConvItem(qint64 peerUid) const
+{
+    for (int i = 0; i < m_convList->count(); ++i)
+    {
+        auto *item = m_convList->item(i);
+        if (item->data(RolePeerUid).toLongLong() == peerUid)
+            return item;
+    }
+    return nullptr;
+}
+
+void Client::upsertConv(qint64 peerUid, const QString &name, const QString &preview,
+                        qint64 ts, int unread)
+{
+    Conv c;
+    c.uid = peerUid;
+    c.name = name;
+    c.preview = preview;
+    c.time = formatTs(ts);
+    c.unread = unread;
+    if (auto *exist = findConvItem(peerUid))
+        c.avatarVer = exist->data(RoleAvatarVer).toInt();
+    if (c.avatarVer <= 0)
+        c.avatarVer = avatarVerOf(peerUid);
+    const qint64 keepUid = m_currentPeerUid;
+    m_convList->blockSignals(true);
+    if (auto *item = findConvItem(peerUid))
+    {
+        item->setData(RoleName, name);
+        item->setData(RoleUnread, unread);
+        item->setData(RolePreview, preview);
+        item->setData(RoleTs, ts);
+        item->setData(RoleAvatarVer, c.avatarVer);
+        const int row = m_convList->row(item);
+        if (row > 0)
+        {
+            m_convList->takeItem(row);
+            m_convList->insertItem(0, item);
+        }
+        if (QWidget *old = m_convList->itemWidget(item))
+            old->deleteLater();
+        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+    }
+    else
+    {
+        auto *created = new QListWidgetItem;
+        created->setData(RolePeerUid, peerUid);
+        created->setData(RoleName, name);
+        created->setData(RoleUnread, unread);
+        created->setData(RolePreview, preview);
+        created->setData(RoleTs, ts);
+        created->setData(RoleAvatarVer, c.avatarVer);
+        created->setSizeHint(QSize(200, 68));
+        m_convList->insertItem(0, created);
+        m_convList->setItemWidget(created, makeConvRow(c, m_convList));
+    }
+    if (keepUid > 0)
+    {
+        if (auto *keep = findConvItem(keepUid))
+            m_convList->setCurrentItem(keep);
+    }
+    m_convList->blockSignals(false);
+    persistLocalConv(peerUid, name, preview, ts, unread);
+    applyListFilter();
+    updateTray();
+}
+
+void Client::selectConversation(int row)
+{
+    if (row < 0)
+        return;
+    auto *item = m_convList->item(row);
+    if (!item)
+        return;
+    openChat(item->data(RolePeerUid).toLongLong(), item->data(RoleName).toString());
+}
+
+void Client::openChat(qint64 peerUid, const QString &name)
+{
+    if (peerUid <= 0)
+        return;
+    m_currentPeerUid = peerUid;
+    m_currentPeerName = name;
+    m_chatTitle->setText(name);
+    m_chatStack->setCurrentWidget(m_chatBody);
+    m_stickChatToBottom = true;
+    applyNavSelection(0);
+    markPeerRead(peerUid);
+    if (auto *item = findConvItem(peerUid))
+    {
+        m_convList->blockSignals(true);
+        m_convList->setCurrentItem(item);
+        m_convList->blockSignals(false);
+    }
+    else
+    {
+        upsertConv(peerUid, name, QString(), QDateTime::currentMSecsSinceEpoch(), 0);
+        if (auto *item = findConvItem(peerUid))
+        {
+            m_convList->blockSignals(true);
+            m_convList->setCurrentItem(item);
+            m_convList->blockSignals(false);
+        }
+    }
+
+    QPointer<Client> self(this);
+    applyHistory(QJsonArray());
+    const int limit = AppConfig::instance().historyDefault();
+    ImClient::instance().msgHistory(peerUid, 0, limit, [self, peerUid](const QJsonObject &reply) {
+        if (!self || self->m_currentPeerUid != peerUid)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->applyHistory(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void Client::applyUnread(qint64 peerUid, int unread)
+{
+    auto *item = findConvItem(peerUid);
+    if (!item)
+        return;
+    item->setData(RoleUnread, unread);
+    Conv c;
+    c.uid = peerUid;
+    c.name = item->data(RoleName).toString();
+    c.preview = item->data(RolePreview).toString();
+    c.time = formatTs(item->data(RoleTs).toLongLong());
+    c.unread = unread;
+    c.avatarVer = item->data(RoleAvatarVer).toInt();
+    if (QWidget *old = m_convList->itemWidget(item))
+        old->deleteLater();
+    m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+    LocalStore::instance().setUnread(peerUid, unread);
+    updateTray();
+}
+
+void Client::onConvMenu(const QPoint &pos)
+{
+    auto *item = m_convList->itemAt(pos);
+    if (!item)
+        return;
+    const qint64 uid = item->data(RolePeerUid).toLongLong();
+    const QString name = item->data(RoleName).toString();
+    const int unread = item->data(RoleUnread).toInt();
+    QMenu menu(this);
+    QAction *del = menu.addAction(QStringLiteral("删除"));
+    QAction *read = menu.addAction(QStringLiteral("标记已读"));
+    QAction *unreadAct = menu.addAction(QStringLiteral("标记未读"));
+    read->setEnabled(unread > 0);
+    unreadAct->setEnabled(unread <= 0);
+    QAction *picked = menu.exec(m_convList->viewport()->mapToGlobal(pos));
+    if (!picked)
+        return;
+    QPointer<Client> self(this);
+    if (picked == del)
+    {
+        if (!ConfirmDialog::ask(this, QStringLiteral("删除聊天"),
+                                QStringLiteral("删除后,将永久清空与「%1」的聊天记录").arg(name)))
+            return;
+        ImClient::instance().deleteConv(uid, [self, uid](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->removeLocalConv(uid);
+        });
+        return;
+    }
+    const int next = (picked == read) ? 0 : 1;
+    ImClient::instance().setUnread(uid, next, [self, uid, next](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->applyUnread(uid, next);
+    });
+}
+
+void Client::onFriendMenu(const QPoint &pos)
+{
+    auto *item = m_friendList->itemAt(pos);
+    if (!item)
+        return;
+    const qint64 uid = item->data(RolePeerUid).toLongLong();
+    const QString name = item->data(RoleName).toString();
+    const QString remark = item->data(RoleRemark).toString();
+    QMenu menu(this);
+    QAction *del = nullptr;
+    QAction *mark = nullptr;
+    if (uid != im::kFileHelperUid)
+    {
+        del = menu.addAction(QStringLiteral("删除"));
+        mark = menu.addAction(QStringLiteral("备注"));
+    }
+    QAction *card = menu.addAction(QStringLiteral("查看名片"));
+    QAction *picked = menu.exec(m_friendList->viewport()->mapToGlobal(pos));
+    if (!picked)
+        return;
+    if (picked == card)
+    {
+        showProfile(uid);
+        return;
+    }
+    QPointer<Client> self(this);
+    if (picked == mark)
+    {
+        QString next = remark;
+        if (!PromptDialog::ask(this, QStringLiteral("设置备注"),
+                               QStringLiteral("不填则显示昵称"), &next, 32))
+            return;
+        ImClient::instance().setRemark(uid, next, [self, uid, next](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            const QString shown = next.isEmpty()
+                ? reply.value(QStringLiteral("nickname")).toString()
+                : next;
+            self->refreshFriendList();
+            if (auto *it = self->findConvItem(uid))
+            {
+                const qint64 ts = it->data(RoleTs).toLongLong();
+                self->upsertConv(uid, shown.isEmpty() ? it->data(RoleName).toString() : shown,
+                                 it->data(RolePreview).toString(), ts,
+                                 it->data(RoleUnread).toInt());
+            }
+            if (self->m_currentPeerUid == uid && !shown.isEmpty())
+            {
+                self->m_currentPeerName = shown;
+                if (self->m_chatTitle)
+                    self->m_chatTitle->setText(shown);
+            }
+        });
+        return;
+    }
+    if (picked == del)
+    {
+        if (!ConfirmDialog::ask(
+                this, QStringLiteral("删除好友"),
+                QStringLiteral("将好友「%1」删除,同时删除与该好友的聊天记录?").arg(name)))
+            return;
+        ImClient::instance().deleteFriend(uid, [self, uid](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->removeLocalConv(uid);
+            self->refreshFriendList();
+        });
+    }
+}
+
+void Client::showProfile(qint64 uid)
+{
+    QPointer<Client> self(this);
+    ImClient::instance().getProfile(uid, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const qint64 peer = Proto::jsonI64(reply, QStringLiteral("uid"));
+        QString nick = reply.value(QStringLiteral("nickname")).toString().trimmed();
+        const QString account = reply.value(QStringLiteral("account")).toString();
+        const QString remark = reply.value(QStringLiteral("remark")).toString().trimmed();
+        const QString sign = reply.value(QStringLiteral("signature")).toString().trimmed();
+        if (nick.isEmpty())
+            nick = account;
+        const QString shown = remark.isEmpty() ? nick : remark;
+
+        QDialog card(self);
+        card.setObjectName(QStringLiteral("ChromeWindow"));
+        card.setWindowTitle(QStringLiteral("名片"));
+        card.setFixedSize(360, 400);
+        UiHelpers::setupFramelessDialog(&card);
+
+        auto *root = new QVBoxLayout(&card);
+        root->setContentsMargins(0, 0, 0, 0);
+        root->setSpacing(0);
+        auto *bar = new TitleBar(&card);
+        bar->setTitle(QStringLiteral("名片"));
+        bar->setTheme(TitleBar::Light);
+        bar->setMaximizable(false);
+        bar->bind(&card, false);
+        if (peer != im::kFileHelperUid)
+            bar->setActionText(QStringLiteral("分享"));
+        root->addWidget(bar);
+
+        auto *page = new QWidget(&card);
+        auto *lay = new QVBoxLayout(page);
+        lay->setContentsMargins(28, 20, 28, 22);
+        lay->setSpacing(8);
+        auto *avatar = new QLabel(page);
+        avatar->setFixedSize(64, 64);
+        avatar->setPixmap(AvatarCache::instance().pixmap(
+            peer, reply.value(QStringLiteral("avatar_ver")).toInt(), shown, 64));
+        auto *nameLab = new QLabel(shown, page);
+        nameLab->setAlignment(Qt::AlignCenter);
+        nameLab->setStyleSheet(QStringLiteral("font-size:16px; color:#1A1A1A; background:transparent; border:none;"));
+        const QString meta = QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent; border:none;");
+        auto *remarkLab = new QLabel(QStringLiteral("备注  %1").arg(remark.isEmpty() ? QStringLiteral("未设置") : remark), page);
+        remarkLab->setAlignment(Qt::AlignCenter);
+        remarkLab->setStyleSheet(meta);
+        auto *nickLab = new QLabel(QStringLiteral("昵称  %1").arg(nick), page);
+        nickLab->setAlignment(Qt::AlignCenter);
+        nickLab->setStyleSheet(meta);
+        auto *accLab = new QLabel(QStringLiteral("账号  %1").arg(account), page);
+        accLab->setAlignment(Qt::AlignCenter);
+        accLab->setStyleSheet(meta);
+        auto *signLab = new QLabel(sign.isEmpty() ? QStringLiteral("这个人很懒,什么都没写") : sign, page);
+        signLab->setAlignment(Qt::AlignCenter);
+        signLab->setWordWrap(true);
+        signLab->setStyleSheet(QStringLiteral("font-size:13px; color:#8A8A8A; background:transparent; border:none;"));
+        auto *chatBtn = UiHelpers::compactButton(QStringLiteral("发消息"), page, true);
+        const int rel = reply.value(QStringLiteral("relation")).toInt();
+        const bool canChat = peer != self->m_uid
+            && (peer == im::kFileHelperUid || rel == im::FriendRel::Friend);
+        chatBtn->setVisible(canChat);
+        lay->addWidget(avatar, 0, Qt::AlignHCenter);
+        lay->addWidget(nameLab);
+        lay->addWidget(remarkLab);
+        lay->addWidget(nickLab);
+        lay->addWidget(accLab);
+        lay->addWidget(signLab);
+        lay->addStretch();
+        lay->addWidget(chatBtn, 0, Qt::AlignHCenter);
+        root->addWidget(page, 1);
+        QObject::connect(chatBtn, &QPushButton::clicked, &card, [&card, self, peer, shown]() {
+            card.accept();
+            if (self)
+                self->openChat(peer, shown);
+        });
+        QObject::connect(bar, &TitleBar::actionClicked, &card, [self, reply, &card]() {
+            // 先关掉名片,避免在已有 exec() 里再套一层转发框(Windows 上经常不显示)。
+            card.accept();
+            if (self)
+                self->shareUserCard(reply);
+        });
+        card.exec();
+    });
+}
+
+void Client::showFriendCard(qint64 uid)
+{
+    if (uid <= 0)
+        return;
+    QPointer<Client> self(this);
+    ImClient::instance().getProfile(uid, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->fillProfilePage(reply);
+        if (self->m_chatStack && self->m_profilePage)
+            self->m_chatStack->setCurrentWidget(self->m_profilePage);
+    });
+}
+
+void Client::fillProfilePage(const QJsonObject &user)
+{
+    m_profileUser = user;
+    m_profileUid = Proto::jsonI64(user, QStringLiteral("uid"));
+    m_profileAvatarVer = user.value(QStringLiteral("avatar_ver")).toInt();
+    QString nick = user.value(QStringLiteral("nickname")).toString().trimmed();
+    const QString account = user.value(QStringLiteral("account")).toString();
+    const QString remark = user.value(QStringLiteral("remark")).toString().trimmed();
+    const QString sign = user.value(QStringLiteral("signature")).toString().trimmed();
+    if (nick.isEmpty())
+        nick = account;
+    m_profileDisplayName = remark.isEmpty() ? nick : remark;
+    if (m_profileAvatar)
+        m_profileAvatar->setPixmap(peerAvatar(m_profileUid, m_profileDisplayName, 72));
+    if (m_profileTitle)
+        m_profileTitle->setText(m_profileDisplayName);
+    if (m_profileRemark)
+        m_profileRemark->setText(QStringLiteral("备注  %1").arg(remark.isEmpty() ? QStringLiteral("未设置") : remark));
+    if (m_profileNick)
+        m_profileNick->setText(QStringLiteral("昵称  %1").arg(nick));
+    if (m_profileAccount)
+        m_profileAccount->setText(QStringLiteral("账号  %1").arg(account));
+    if (m_profileSign)
+        m_profileSign->setText(sign.isEmpty() ? QStringLiteral("这个人很懒,什么都没写") : sign);
+    const int rel = user.value(QStringLiteral("relation")).toInt();
+    const bool canChat = m_profileUid != m_uid
+        && (m_profileUid == im::kFileHelperUid || rel == im::FriendRel::Friend);
+    if (m_profileChatBtn)
+        m_profileChatBtn->setVisible(canChat);
+    if (m_profileShareBtn)
+        m_profileShareBtn->setVisible(m_profileUid > 0 && m_profileUid != im::kFileHelperUid);
+}
+
+void Client::removeLocalConv(qint64 uid)
+{
+    if (m_currentPeerUid == uid)
+    {
+        m_currentPeerUid = 0;
+        m_currentPeerName.clear();
+        clearBubbles();
+        if (m_chatStack && m_chatEmpty)
+            m_chatStack->setCurrentWidget(m_chatEmpty);
+    }
+    if (m_profileUid == uid)
+    {
+        m_profileUid = 0;
+        if (m_chatStack && m_chatStack->currentWidget() == m_profilePage)
+        {
+            if (m_currentPeerUid > 0 && m_chatBody)
+                m_chatStack->setCurrentWidget(m_chatBody);
+            else if (m_chatEmpty)
+                m_chatStack->setCurrentWidget(m_chatEmpty);
+        }
+    }
+    if (auto *it = findConvItem(uid))
+        delete it;
+    LocalStore::instance().deletePeer(uid);
+    applyListFilter();
+    updateTray();
+}
+
+void Client::persistWindow()
+{
+    QRect r;
+    if (auto *h = m_titleBar ? m_titleBar->framelessHelper() : nullptr)
+        r = h->normalGeometry();
+    if (!r.isValid() || r.width() < 100 || r.height() < 100)
+    {
+        if (!isMaximized())
+            r = geometry();
+        else
+            r = UserSettings::instance().windowRect();
+    }
+    if (r.width() < 760)
+        r.setWidth(760);
+    if (r.height() < 500)
+        r.setHeight(500);
+    UserSettings::instance().saveWindow(r, isMaximized());
+}
+
+void Client::markPeerRead(qint64 peerUid)
+{
+    if (peerUid <= 0)
+        return;
+    applyUnread(peerUid, 0);
+    ImClient::instance().setUnread(peerUid, 0, {});
+}
+
+void Client::saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
+                              int msgType, const QString &content, qint64 ts, int status)
+{
+    if (msgId == 0 || peerUid <= 0)
+        return;
+    LocalStore::Msg m;
+    m.id = msgId;
+    m.peerUid = peerUid;
+    m.fromUid = fromUid;
+    m.toUid = toUid > 0 ? toUid : ((fromUid == m_uid) ? peerUid : m_uid);
+    m.msgType = msgType;
+    m.content = content;
+    m.ts = ts;
+    m.status = status;
+    LocalStore::instance().upsertMessage(m);
+}
+
+void Client::persistLocalConv(qint64 peerUid, const QString &name, const QString &preview,
+                              qint64 ts, int unread)
+{
+    LocalStore::Conv c;
+    c.peerUid = peerUid;
+    c.name = name;
+    c.preview = preview;
+    c.ts = ts;
+    c.unread = unread;
+    LocalStore::instance().upsertConversation(c);
+}
+
+void Client::loadLocalConvs()
+{
+    const auto rows = LocalStore::instance().listConversations();
+    if (rows.isEmpty())
+        return;
+    QJsonArray arr;
+    for (const auto &c : rows)
+    {
+        QJsonObject o;
+        o.insert(QStringLiteral("peer_uid"), c.peerUid);
+        o.insert(QStringLiteral("nickname"), c.name);
+        o.insert(QStringLiteral("preview"), c.preview);
+        o.insert(QStringLiteral("ts"), c.ts);
+        o.insert(QStringLiteral("unread"), c.unread);
+        arr.append(o);
+    }
+    applyConvList(arr);
+}
+
+bool Client::isViewingPeer(qint64 peerUid) const
+{
+    return peerUid > 0 && peerUid == m_currentPeerUid
+        && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
+}
+
+void Client::sendCurrentMessage()
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
+        return;
+    const QString text = m_input->toPlainText().trimmed();
+    if (text.isEmpty())
+        return;
+    if (text.size() > AppConfig::instance().contentMax())
+    {
+        UiHelpers::showToast(this, QStringLiteral("消息过长"), true);
+        return;
+    }
+
+    m_input->clear();
+    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Text, text,
+                 previewOf(im::MsgType::Text, text));
+}
+
+void Client::clearBubbles()
+{
+    while (m_bubbleLay->count() > 1)
+    {
+        QLayoutItem *it = m_bubbleLay->takeAt(0);
+        if (it->widget())
+            it->widget()->deleteLater();
+        delete it;
+    }
+}
+
+QWidget *Client::addBubble(bool mine, const QString &name, const QString &text, const QString &time,
+                           int msgType, int sendStatus, qint64 msgId, qint64 peerUid)
+{
+    if (!time.isEmpty())
+    {
+        auto *stamp = new QLabel(time, m_bubbleHost);
+        stamp->setAlignment(Qt::AlignCenter);
+        stamp->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:12px; background:transparent; padding:4px 0 8px 0;"));
+        m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, stamp);
+    }
+
+    auto *row = new QWidget(m_bubbleHost);
+    auto *lay = new QHBoxLayout(row);
+    lay->setContentsMargins(0, 0, 0, 0);
+    lay->setSpacing(8);
+
+    auto *avatar = new QLabel(row);
+    avatar->setFixedSize(36, 36);
+    const qint64 avatarUid = mine ? m_uid : m_currentPeerUid;
+    avatar->setPixmap(peerAvatar(avatarUid, name, 36));
+
+    QWidget *bubble = nullptr;
+    if (msgType == im::MsgType::Sticker)
+    {
+        qint64 packId = 0;
+        QString code;
+        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        packId = Proto::jsonI64(o, QStringLiteral("pack_id"));
+        code = o.value(QStringLiteral("code")).toString();
+        auto *img = new QLabel(row);
+        img->setFixedSize(72, 72);
+        img->setScaledContents(true);
+        const QPixmap pm = StickerCache::instance().pixmap(packId, code, 72);
+        if (pm.isNull())
+        {
+            img->setText(QStringLiteral("[表情]"));
+            img->setAlignment(Qt::AlignCenter);
+            img->setStyleSheet(QStringLiteral("color:#888; background:transparent;"));
+            StickerCache::instance().sync();
+        }
+        else
+        {
+            img->setPixmap(pm);
+        }
+        bubble = img;
+    }
+    else if (msgType == im::MsgType::File)
+    {
+        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        const QString fname = o.value(QStringLiteral("name")).toString(QStringLiteral("文件"));
+        const qint64 fsize = Proto::jsonI64(o, QStringLiteral("size"));
+        const qint64 fileId = Proto::jsonI64(o, QStringLiteral("file_id"));
+        const QString mime = o.value(QStringLiteral("mime")).toString();
+        const QString fileUrl = o.value(QStringLiteral("url")).toString();
+        if (FileCache::looksLikeImage(mime, fname))
+        {
+            auto *box = new ClickWidget(row);
+            box->setStyleSheet(QStringLiteral("background:transparent; border:none;"));
+            auto *img = new QLabel(box);
+            img->setAlignment(Qt::AlignCenter);
+            img->setStyleSheet(QStringLiteral(
+                "background:#F0F0F0; color:#888; border:none; border-radius:4px;"));
+            const QPixmap pm = FileCache::instance().thumbnail(fileId, 180);
+            if (pm.isNull())
+            {
+                img->setFixedSize(120, 90);
+                img->setText(QStringLiteral("[图片]"));
+                FileCache::instance().ensure(fileId, fileUrl, mime, fname);
+            }
+            else
+            {
+                img->setPixmap(pm);
+                const qreal dpr = qMax(1.0, pm.devicePixelRatio());
+                img->setFixedSize(qRound(pm.width() / dpr), qRound(pm.height() / dpr));
+            }
+            auto *layImg = new QVBoxLayout(box);
+            layImg->setContentsMargins(0, 0, 0, 0);
+            layImg->addWidget(img);
+            QPointer<QLabel> imgRef(img);
+            connect(&FileCache::instance(), &FileCache::ready, img, [imgRef, fileId](qint64 id) {
+                if (!imgRef || id != fileId)
+                    return;
+                const QPixmap got = FileCache::instance().thumbnail(fileId, 180);
+                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));
+            });
+            box->onClick = [this, fileId, fname, text]() { previewChatImage(fileId, fname, text); };
+            box->onForward = [this, text]() {
+                forwardPayload(im::MsgType::File, text, QStringLiteral("[图片]"));
+            };
+            bubble = box;
+        }
+        else
+        {
+            auto *box = new FileBubble(row, fileId, fname, fsize, fileUrl, mime, mine);
+            box->onForward = [this, text, fname]() {
+                forwardPayload(im::MsgType::File, text, QStringLiteral("[文件] %1").arg(fname));
+            };
+            bubble = box;
+        }
+    }
+    else if (msgType == im::MsgType::Card)
+    {
+        const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        const qint64 cardUid = Proto::jsonI64(o, QStringLiteral("uid"));
+        QString cardName = o.value(QStringLiteral("nickname")).toString();
+        const QString acc = o.value(QStringLiteral("account")).toString();
+        if (cardName.isEmpty())
+            cardName = acc;
+        const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *box = new ClickWidget(row);
+        box->setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
+        auto *h = new QHBoxLayout(box);
+        h->setContentsMargins(10, 8, 12, 8);
+        h->setSpacing(8);
+        auto *av = new QLabel(box);
+        av->setFixedSize(40, 40);
+        av->setPixmap(AvatarCache::instance().pixmap(cardUid, ver, cardName, 40));
+        auto *info = new QVBoxLayout();
+        info->setContentsMargins(0, 0, 0, 0);
+        info->setSpacing(2);
+        auto *n = new QLabel(cardName, box);
+        n->setStyleSheet(QStringLiteral("color:#191919; font-size:13px; background:transparent;"));
+        auto *a = new QLabel(QStringLiteral("账号  %1").arg(acc), box);
+        a->setStyleSheet(QStringLiteral("color:#888888; font-size:11px; background:transparent;"));
+        info->addWidget(n);
+        info->addWidget(a);
+        h->addWidget(av);
+        h->addLayout(info, 1);
+        box->setFixedWidth(220);
+        box->onClick = [this, cardUid]() {
+            if (cardUid > 0)
+                showProfile(cardUid);
+        };
+        box->onForward = [this, text, cardName]() {
+            forwardPayload(im::MsgType::Card, text, QStringLiteral("[名片] %1").arg(cardName));
+        };
+        bubble = box;
+    }
+    else
+    {
+        auto *lab = new QLabel(text, row);
+        lab->setWordWrap(true);
+        lab->setMaximumWidth(360);
+        lab->setTextInteractionFlags(Qt::TextSelectableByMouse);
+        lab->setStyleSheet(mine
+            ? QStringLiteral("background:#95EC69; color:#191919; padding:8px 10px; border:none; border-radius:4px;")
+            : QStringLiteral("background:#FFFFFF; color:#191919; padding:8px 10px; border:none; border-radius:4px;"));
+        bubble = lab;
+    }
+
+    auto *sendMark = new QLabel(row);
+    sendMark->setObjectName(QStringLiteral("sendStatus"));
+    sendMark->setFixedSize(16, 16);
+    sendMark->setAlignment(Qt::AlignCenter);
+    sendMark->hide();
+    sendMark->installEventFilter(this);
+
+    if (mine)
+    {
+        row->setProperty("outgoingId", QVariant::fromValue(msgId));
+        row->setProperty("outgoingPeer", QVariant::fromValue(peerUid > 0 ? peerUid : m_currentPeerUid));
+        row->setProperty("outgoingType", msgType);
+        row->setProperty("outgoingContent", text);
+        lay->addStretch();
+        lay->addWidget(sendMark, 0, Qt::AlignVCenter);
+        lay->addWidget(bubble, 0, Qt::AlignTop);
+        lay->addWidget(avatar, 0, Qt::AlignTop);
+    }
+    else
+    {
+        lay->addWidget(avatar, 0, Qt::AlignTop);
+        lay->addWidget(bubble, 0, Qt::AlignTop);
+        lay->addStretch();
+        sendMark->hide();
+    }
+
+    m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, row);
+    if (sendStatus == LocalMsg::Failed)
+        setBubbleSendState(row, true);
+    else if (sendStatus == LocalMsg::Sending)
+        setBubbleSendState(row, false, true);
+    if (sendStatus == LocalMsg::Sending || m_stickChatToBottom)
+        scrollChatToBottom();
+    return row;
+}
+
+void Client::setBubbleSendState(QWidget *row, bool failed, bool sending)
+{
+    if (!row)
+        return;
+    auto *mark = row->findChild<QLabel *>(QStringLiteral("sendStatus"));
+    if (!mark)
+        return;
+    if (failed)
+    {
+        row->setProperty("sendStatus", LocalMsg::Failed);
+        mark->setText(QStringLiteral("!"));
+        mark->setToolTip(QStringLiteral("点击重发"));
+        mark->setCursor(Qt::PointingHandCursor);
+        mark->setStyleSheet(QStringLiteral(
+            "QLabel { color:#FFFFFF; background:#FA5151; border:none; border-radius:8px;"
+            " font-size:11px; font-weight:bold; }"));
+        mark->show();
+        return;
+    }
+    if (sending)
+    {
+        row->setProperty("sendStatus", LocalMsg::Sending);
+        mark->setText(QStringLiteral("…"));
+        mark->setToolTip(QString());
+        mark->setCursor(Qt::ArrowCursor);
+        mark->setStyleSheet(QStringLiteral(
+            "QLabel { color:#888888; background:transparent; border:none; font-size:12px; }"));
+        mark->show();
+        return;
+    }
+    row->setProperty("sendStatus", LocalMsg::Ok);
+    mark->setToolTip(QString());
+    mark->setCursor(Qt::ArrowCursor);
+    mark->clear();
+    mark->hide();
+}
+
+QWidget *Client::findOutgoingRow(qint64 localId) const
+{
+    if (!m_bubbleLay || localId == 0)
+        return nullptr;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QLayoutItem *it = m_bubbleLay->itemAt(i);
+        QWidget *w = it ? it->widget() : nullptr;
+        if (w && w->property("outgoingId").toLongLong() == localId)
+            return w;
+    }
+    return nullptr;
+}
+
+void Client::scrollChatToBottom()
+{
+    m_stickChatToBottom = true;
+    if (!m_scroll)
+        return;
+    QScrollBar *bar = m_scroll->verticalScrollBar();
+    bar->setValue(bar->maximum());
+    QTimer::singleShot(0, this, [this]() {
+        if (!m_scroll || !m_stickChatToBottom)
+            return;
+        QScrollBar *bar = m_scroll->verticalScrollBar();
+        bar->setValue(bar->maximum());
+    });
+}
+
+void Client::onChangePassword()
+{
+    ChangePasswordDialog dlg(this);
+    if (dlg.exec() == QDialog::Accepted)
+        UiHelpers::showToast(this, QStringLiteral("密码已修改"));
+}
+
+void Client::onLogout()
+{
+    if (m_loggingOut)
+        return;
+    m_loggingOut = true;
+    hideReconnectOverlay();
+    if (m_tray)
+        m_tray->hide();
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    QPointer<Client> self(this);
+    ImClient::instance().logout([self](const QJsonObject &) {
+        if (!self)
+            return;
+        ImClient::instance().disconnectFromServer();
+        emit self->loggedOut();
+        self->close();
+    });
+}
+
+void Client::onKicked()
+{
+    if (m_loggingOut)
+        return;
+    m_loggingOut = true;
+    hideReconnectOverlay();
+    if (m_tray)
+        m_tray->hide();
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    UiHelpers::showToast(this, QStringLiteral("账号已在其他设备登录"));
+    ImClient::instance().disconnectFromServer();
+    emit loggedOut();
+    close();
+}
+
+void Client::onDisconnected()
+{
+    if (m_loggingOut || ImClient::instance().isIntentionalClose())
+        return;
+    if (m_reconnecting)
+    {
+        finishReconnectAttempt(false);
+        return;
+    }
+    startReconnect();
+}
+
+void Client::onConnected()
+{
+    if (!m_reconnecting || !m_reconnectInFlight)
+        return;
+    const QString password = reconnectPassword();
+    const QString account = m_account.isEmpty() ? ImClient::instance().account() : m_account;
+    if (account.isEmpty() || password.isEmpty())
+    {
+        finishReconnectAttempt(false);
+        return;
+    }
+    QPointer<Client> self(this);
+    ImClient::instance().login(account, password, [self](const QJsonObject &reply) {
+        if (!self || !self->m_reconnecting)
+            return;
+        if (ImClient::ok(reply))
+        {
+            self->finishReconnectOk();
+            return;
+        }
+        const int code = reply.value(QStringLiteral("code")).toInt();
+        if (code == Proto::Err::AuthFail || code == Proto::Err::BadAccount
+            || code == Proto::Err::Disabled)
+        {
+            self->m_loggingOut = true;
+            self->hideReconnectOverlay();
+            UiHelpers::showToast(self, QStringLiteral("登录已失效,请重新登录"), true);
+            emit self->loggedOut();
+            self->close();
+            return;
+        }
+        self->finishReconnectAttempt(false);
+    });
+}
+
+void Client::onConnectFailed(const QString &)
+{
+    if (!m_reconnecting)
+        return;
+    finishReconnectAttempt(false);
+}
+
+void Client::setupReconnectOverlay(QWidget *parent)
+{
+    m_reconnectMask = new QWidget(parent);
+    m_reconnectMask->setObjectName(QStringLiteral("ReconnectMask"));
+    m_reconnectMask->setAttribute(Qt::WA_StyledBackground, true);
+    m_reconnectMask->setStyleSheet(QStringLiteral(
+        "QWidget#ReconnectMask { background:rgba(0,0,0,88); }"));
+    m_reconnectMask->hide();
+
+    auto *lay = new QVBoxLayout(m_reconnectMask);
+    lay->setContentsMargins(24, 24, 24, 24);
+    lay->addStretch();
+
+    auto *card = new QWidget(m_reconnectMask);
+    card->setObjectName(QStringLiteral("ReconnectCard"));
+    card->setAttribute(Qt::WA_StyledBackground, true);
+    card->setFixedWidth(280);
+    card->setStyleSheet(QStringLiteral(
+        "QWidget#ReconnectCard { background:#FFFFFF; border:none; border-radius:8px; }"));
+    auto *cLay = new QVBoxLayout(card);
+    cLay->setContentsMargins(28, 28, 28, 24);
+    cLay->setSpacing(8);
+    cLay->setAlignment(Qt::AlignHCenter);
+
+    auto *spin = new SpinRing(card);
+    m_reconnectSpin = spin;
+    m_reconnectTitle = new QLabel(QStringLiteral("正在重连中…"), card);
+    m_reconnectTitle->setAlignment(Qt::AlignCenter);
+    m_reconnectTitle->setStyleSheet(QStringLiteral(
+        "font-size:16px; color:#1A1A1A; background:transparent; border:none; font-weight:bold;"));
+    m_reconnectHint = new QLabel(card);
+    m_reconnectHint->setAlignment(Qt::AlignCenter);
+    m_reconnectHint->setWordWrap(true);
+    m_reconnectHint->setStyleSheet(QStringLiteral(
+        "font-size:12px; color:#888888; background:transparent; border:none;"));
+    m_reconnectBtn = UiHelpers::primaryButton(QStringLiteral("重试"), card);
+    m_reconnectBtn->setFixedWidth(120);
+    m_reconnectBtn->hide();
+
+    cLay->addWidget(spin, 0, Qt::AlignHCenter);
+    cLay->addSpacing(6);
+    cLay->addWidget(m_reconnectTitle);
+    cLay->addWidget(m_reconnectHint);
+    cLay->addSpacing(8);
+    cLay->addWidget(m_reconnectBtn, 0, Qt::AlignHCenter);
+
+    lay->addWidget(card, 0, Qt::AlignHCenter);
+    lay->addStretch();
+
+    m_reconnectTimer = new QTimer(this);
+    m_reconnectTimer->setInterval(1000);
+    connect(m_reconnectTimer, &QTimer::timeout, this, &Client::onReconnectTick);
+    connect(m_reconnectBtn, &QPushButton::clicked, this, [this]() { startReconnect(); });
+}
+
+QString Client::reconnectPassword() const
+{
+    const QString session = ImClient::instance().sessionPassword();
+    if (!session.isEmpty())
+        return session;
+    return UserSettings::instance().lastPassword();
+}
+
+void Client::updateReconnectUi()
+{
+    if (!m_reconnectMask)
+        return;
+    m_reconnectMask->show();
+    m_reconnectMask->raise();
+    if (m_reconnectInFlight)
+    {
+        if (m_reconnectSpin)
+            m_reconnectSpin->show();
+        if (m_reconnectBtn)
+            m_reconnectBtn->hide();
+        m_reconnectTitle->setText(QStringLiteral("正在重连中…"));
+        m_reconnectHint->setText(QStringLiteral("第 %1 / %2 次")
+                                     .arg(m_reconnectAttempt)
+                                     .arg(kReconnectMaxTries));
+        return;
+    }
+    if (m_reconnectWaitLeft > 0)
+    {
+        if (m_reconnectSpin)
+            m_reconnectSpin->show();
+        if (m_reconnectBtn)
+            m_reconnectBtn->hide();
+        m_reconnectTitle->setText(QStringLiteral("正在重连中…"));
+        m_reconnectHint->setText(QStringLiteral("%1 秒后进行第 %2 / %3 次")
+                                     .arg(m_reconnectWaitLeft)
+                                     .arg(m_reconnectAttempt + 1)
+                                     .arg(kReconnectMaxTries));
+        return;
+    }
+}
+
+void Client::hideReconnectOverlay()
+{
+    m_reconnecting = false;
+    m_reconnectInFlight = false;
+    m_reconnectAttempt = 0;
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    if (m_reconnectMask)
+        m_reconnectMask->hide();
+}
+
+void Client::startReconnect()
+{
+    if (m_loggingOut)
+        return;
+    m_reconnecting = true;
+    m_reconnectInFlight = false;
+    m_reconnectAttempt = 0;
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    if (m_reconnectMask)
+    {
+        m_reconnectMask->show();
+        m_reconnectMask->raise();
+    }
+    attemptReconnect();
+}
+
+void Client::attemptReconnect()
+{
+    if (m_loggingOut || !m_reconnecting)
+        return;
+    if (m_reconnectAttempt >= kReconnectMaxTries)
+    {
+        showReconnectFailed();
+        return;
+    }
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    ++m_reconnectAttempt;
+    m_reconnectInFlight = true;
+    updateReconnectUi();
+
+    if (ImClient::instance().isConnected())
+    {
+        onConnected();
+        return;
+    }
+    ImClient::instance().connectToServer();
+}
+
+void Client::scheduleReconnectRetry()
+{
+    if (m_reconnectAttempt >= kReconnectMaxTries)
+    {
+        showReconnectFailed();
+        return;
+    }
+    m_reconnectInFlight = false;
+    m_reconnectWaitLeft = kReconnectIntervalSec;
+    if (m_reconnectTimer)
+        m_reconnectTimer->start();
+    updateReconnectUi();
+}
+
+void Client::onReconnectTick()
+{
+    if (!m_reconnecting || m_reconnectInFlight)
+        return;
+    if (m_reconnectWaitLeft > 0)
+        --m_reconnectWaitLeft;
+    if (m_reconnectWaitLeft <= 0)
+    {
+        if (m_reconnectTimer)
+            m_reconnectTimer->stop();
+        attemptReconnect();
+        return;
+    }
+    updateReconnectUi();
+}
+
+void Client::finishReconnectAttempt(bool ok)
+{
+    if (!m_reconnecting || !m_reconnectInFlight)
+        return;
+    m_reconnectInFlight = false;
+    if (ok)
+    {
+        finishReconnectOk();
+        return;
+    }
+    scheduleReconnectRetry();
+}
+
+void Client::finishReconnectOk()
+{
+    const QString nick = ImClient::instance().nickname();
+    if (!nick.isEmpty())
+        m_nickname = nick;
+    hideReconnectOverlay();
+    refreshConvList();
+    refreshFriendList();
+}
+
+void Client::showReconnectFailed()
+{
+    m_reconnecting = true;
+    m_reconnectInFlight = false;
+    m_reconnectWaitLeft = 0;
+    if (m_reconnectTimer)
+        m_reconnectTimer->stop();
+    if (m_reconnectSpin)
+        m_reconnectSpin->hide();
+    if (m_reconnectBtn)
+        m_reconnectBtn->show();
+    if (m_reconnectTitle)
+        m_reconnectTitle->setText(QStringLiteral("连接已断开"));
+    if (m_reconnectHint)
+        m_reconnectHint->setText(QStringLiteral("已重试 %1 次,请检查网络后重试")
+                                     .arg(kReconnectMaxTries));
+    if (m_reconnectMask)
+    {
+        m_reconnectMask->show();
+        m_reconnectMask->raise();
+    }
+}
+
+void Client::onChatNotify(const QJsonObject &msg)
+{
+    const qint64 from = Proto::jsonI64(msg, QStringLiteral("from_uid"));
+    const qint64 to = Proto::jsonI64(msg, QStringLiteral("to_uid"));
+    const qint64 peer = (from == m_uid) ? to : from;
+    QString name = msg.value(QStringLiteral("from_nickname")).toString();
+    if (name.isEmpty())
+        name = msg.value(QStringLiteral("from_account")).toString();
+    if (from == m_uid)
+    {
+        if (auto *item = findConvItem(peer))
+            name = item->data(RoleName).toString();
+    }
+    const QString text = msg.value(QStringLiteral("content")).toString();
+    const int msgType = msg.value(QStringLiteral("msg_type")).toInt(1);
+    const qint64 ts = Proto::jsonI64(msg, QStringLiteral("ts"));
+    qint64 msgId = Proto::jsonI64(msg, QStringLiteral("msg_id"));
+    if (msgId <= 0)
+        msgId = Proto::jsonI64(msg, QStringLiteral("id"));
+    saveLocalMessage(msgId, peer, from, to, msgType, text, ts);
+
+    const bool viewing = isViewingPeer(peer) && isVisible() && !isMinimized();
+    int unread = 0;
+    if (!viewing)
+    {
+        unread = 1;
+        if (auto *item = findConvItem(peer))
+            unread = item->data(RoleUnread).toInt() + 1;
+    }
+    upsertConv(peer, name, previewOf(msgType, text), ts, unread);
+    if (viewing && from != m_uid)
+    {
+        addBubble(false, name, text, formatTs(ts), msgType);
+        markPeerRead(peer);
+    }
+    if (from != m_uid)
+    {
+        const bool quiet = viewing && isVisible() && !isMinimized() && isActiveWindow();
+        if (!quiet)
+            UiHelpers::playNotifySound();
+    }
+}
+
+bool Client::eventFilter(QObject *watched, QEvent *event)
+{
+    if (event->type() == QEvent::MouseButtonRelease)
+    {
+        if (auto *lab = qobject_cast<QLabel *>(watched))
+        {
+            if (lab->objectName() == QStringLiteral("sendStatus"))
+            {
+                auto *me = static_cast<QMouseEvent *>(event);
+                if (me->button() == Qt::LeftButton)
+                    resendFailed(lab->parentWidget());
+                return true;
+            }
+        }
+    }
+    if (watched == m_navAvatar && event->type() == QEvent::MouseButtonRelease)
+    {
+        auto *me = static_cast<QMouseEvent *>(event);
+        if (me->button() == Qt::LeftButton)
+            showSettings(0);
+        return true;
+    }
+    if (watched == m_chatTitle && event->type() == QEvent::MouseButtonRelease)
+    {
+        auto *me = static_cast<QMouseEvent *>(event);
+        if (me->button() == Qt::LeftButton && m_currentPeerUid > 0
+            && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
+            showProfile(m_currentPeerUid);
+        return true;
+    }
+    if (watched == m_input && event->type() == QEvent::KeyPress)
+    {
+        auto *key = static_cast<QKeyEvent *>(event);
+        if (key->key() == Qt::Key_Return || key->key() == Qt::Key_Enter)
+        {
+            if (key->modifiers() & Qt::ShiftModifier)
+                return QMainWindow::eventFilter(watched, event);
+            sendCurrentMessage();
+            return true;
+        }
+    }
+    return QMainWindow::eventFilter(watched, event);
+}
+
+void Client::showSettings()
+{
+    showSettings(1);
+}
+
+void Client::showSettings(int page)
+{
+    SettingsDialog dlg(m_account, m_nickname, ImClient::instance().signature(), this, page);
+    dlg.exec();
+    m_nickname = ImClient::instance().nickname();
+    refreshAvatar(m_uid);
+    if (dlg.wantsLogout())
+        onLogout();
+    else if (dlg.wantsChangePassword())
+        onChangePassword();
+}
+
+void Client::applyListFilter()
+{
+    const QString key = m_search ? m_search->text().trimmed() : QString();
+    const bool friendsTab = m_sideStack && m_sideStack->currentIndex() == 1;
+
+    auto applyOne = [&](QListWidget *list, QStackedWidget *stack, QLabel *empty, bool useKey,
+                        const QString &miss, const QString &none) {
+        if (!list || !stack || !empty)
+            return;
+        int visible = 0;
+        for (int i = 0; i < list->count(); ++i)
+        {
+            auto *item = list->item(i);
+            const bool hide = useKey && !key.isEmpty() &&
+                !item->data(RoleName).toString().contains(key, Qt::CaseInsensitive);
+            item->setHidden(hide);
+            if (!hide)
+                ++visible;
+        }
+        if (visible == 0)
+        {
+            empty->setText((useKey && !key.isEmpty()) ? miss : none);
+            stack->setCurrentWidget(empty);
+        }
+        else
+        {
+            stack->setCurrentWidget(list);
+        }
+    };
+
+    applyOne(m_convList, m_convStack, m_convEmpty, !friendsTab,
+             QStringLiteral("未找到相关会话"), QStringLiteral("暂无消息"));
+    applyOne(m_friendList, m_friendStack, m_friendEmpty, friendsTab,
+             QStringLiteral("未找到相关好友"), QStringLiteral("暂无好友,点击右上角 + 添加"));
+}
+
+QString Client::previewOf(int msgType, const QString &content) const
+{
+    if (msgType == im::MsgType::Sticker)
+        return QStringLiteral("[表情]");
+    const QJsonObject o = QJsonDocument::fromJson(content.toUtf8()).object();
+    if (msgType == im::MsgType::File)
+    {
+        const QString name = o.value(QStringLiteral("name")).toString();
+        const QString mime = o.value(QStringLiteral("mime")).toString();
+        if (FileCache::looksLikeImage(mime, name))
+            return QStringLiteral("[图片]");
+        return name.isEmpty() ? QStringLiteral("[文件]") : QStringLiteral("[文件] %1").arg(name);
+    }
+    if (msgType == im::MsgType::Card)
+    {
+        QString name = o.value(QStringLiteral("nickname")).toString();
+        if (name.isEmpty())
+            name = o.value(QStringLiteral("account")).toString();
+        return name.isEmpty() ? QStringLiteral("[名片]") : QStringLiteral("[名片] %1").arg(name);
+    }
+    return content;
+}
+
+void Client::sendSticker(qint64 packId, const QString &code)
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || code.isEmpty())
+        return;
+    QJsonObject body;
+    body.insert(QStringLiteral("pack_id"), packId);
+    body.insert(QStringLiteral("code"), code);
+    const QString content = QString::fromUtf8(QJsonDocument(body).toJson(QJsonDocument::Compact));
+    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Sticker, content,
+                 QStringLiteral("[表情]"));
+}
+
+int Client::avatarVerOf(qint64 uid) const
+{
+    if (uid <= 0)
+        return 0;
+    if (uid == m_uid)
+        return ImClient::instance().avatarVer();
+    int ver = 0;
+    if (m_friendList)
+    {
+        for (int i = 0; i < m_friendList->count(); ++i)
+        {
+            auto *item = m_friendList->item(i);
+            if (item && item->data(RolePeerUid).toLongLong() == uid)
+            {
+                ver = item->data(RoleAvatarVer).toInt();
+                break;
+            }
+        }
+    }
+    if (ver <= 0 && uid == m_profileUid)
+        ver = m_profileAvatarVer;
+    if (ver <= 0)
+    {
+        if (auto *item = findConvItem(uid))
+            ver = item->data(RoleAvatarVer).toInt();
+    }
+    return ver;
+}
+
+QPixmap Client::peerAvatar(qint64 uid, const QString &name, int size) const
+{
+    return AvatarCache::instance().pixmap(uid, avatarVerOf(uid), name, size);
+}
+
+void Client::refreshAvatar(qint64 uid)
+{
+    const int known = AvatarCache::instance().knownVer(uid);
+    if (uid == m_uid && m_navAvatar)
+    {
+        const int selfVer = qMax(ImClient::instance().avatarVer(), known);
+        m_navAvatar->setProperty("avatarVer", selfVer);
+        m_navAvatar->setPixmap(peerAvatar(m_uid, m_nickname, 36));
+    }
+    if (auto *item = findConvItem(uid))
+    {
+        Conv c;
+        c.uid = uid;
+        c.name = item->data(RoleName).toString();
+        c.preview = item->data(RolePreview).toString();
+        c.time = formatTs(item->data(RoleTs).toLongLong());
+        c.unread = item->data(RoleUnread).toInt();
+        c.avatarVer = qMax(item->data(RoleAvatarVer).toInt(), known);
+        item->setData(RoleAvatarVer, c.avatarVer);
+        if (QWidget *old = m_convList->itemWidget(item))
+            old->deleteLater();
+        m_convList->setItemWidget(item, makeConvRow(c, m_convList));
+    }
+    if (m_friendList)
+    {
+        for (int i = 0; i < m_friendList->count(); ++i)
+        {
+            auto *item = m_friendList->item(i);
+            if (!item || item->data(RolePeerUid).toLongLong() != uid)
+                continue;
+            const QString name = item->data(RoleName).toString();
+            const bool online = item->data(RoleOnline).toInt() == 1;
+            const int ver = qMax(item->data(RoleAvatarVer).toInt(), known);
+            item->setData(RoleAvatarVer, ver);
+            if (QWidget *old = m_friendList->itemWidget(item))
+                old->deleteLater();
+            m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, ver, m_friendList));
+            break;
+        }
+    }
+    if (uid == m_profileUid)
+    {
+        if (known > m_profileAvatarVer)
+            m_profileAvatarVer = known;
+        if (m_profileAvatar)
+            m_profileAvatar->setPixmap(peerAvatar(uid, m_profileDisplayName, 72));
+    }
+}
+
+void Client::sendChatPayload(int msgType, const QString &content, const QString &preview)
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || content.isEmpty())
+        return;
+    sendChatPayloadTo(m_currentPeerUid, m_currentPeerName, msgType, content, preview);
+}
+
+void Client::sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
+                               const QString &content, const QString &preview)
+{
+    sendOutgoing(peer, peerName, msgType, content, preview);
+}
+
+void Client::sendOutgoing(qint64 peer, const QString &peerName, int msgType,
+                          const QString &content, const QString &preview,
+                          QWidget *existingRow, qint64 localId)
+{
+    if (peer <= 0 || content.isEmpty())
+        return;
+    if (localId == 0)
+        localId = LocalStore::instance().allocLocalId();
+    const qint64 ts = QDateTime::currentMSecsSinceEpoch();
+    saveLocalMessage(localId, peer, m_uid, peer, msgType, content, ts, LocalMsg::Sending);
+    m_inflightLocalIds.insert(localId);
+
+    QPointer<QWidget> row = existingRow;
+    if (row)
+        setBubbleSendState(row, false, true);
+    else if (isViewingPeer(peer))
+        row = addBubble(true, m_nickname, content, QStringLiteral("刚刚"), msgType,
+                        LocalMsg::Sending, localId, peer);
+    upsertConv(peer, peerName, preview, ts, 0);
+
+    QPointer<Client> self(this);
+    ImClient::instance().chat(peer, msgType, content,
+        [self, row, peer, peerName, content, msgType, preview, localId, ts](const QJsonObject &reply) {
+            if (!self)
+                return;
+            self->m_inflightLocalIds.remove(localId);
+            QWidget *markRow = row;
+            if (!markRow)
+                markRow = self->findOutgoingRow(localId);
+            if (!ImClient::ok(reply))
+            {
+                self->saveLocalMessage(localId, peer, self->m_uid, peer, msgType, content,
+                                       ts, LocalMsg::Failed);
+                if (markRow)
+                    self->setBubbleSendState(markRow, true);
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            LocalStore::instance().removeMessage(localId);
+            const qint64 serverId = Proto::jsonI64(reply, QStringLiteral("msg_id"));
+            const qint64 serverTs = Proto::jsonI64(reply, QStringLiteral("ts"));
+            self->saveLocalMessage(serverId, peer, self->m_uid, peer, msgType, content,
+                                   serverTs > 0 ? serverTs : ts, LocalMsg::Ok);
+            if (markRow)
+            {
+                markRow->setProperty("outgoingId", QVariant::fromValue(serverId));
+                self->setBubbleSendState(markRow, false);
+            }
+            self->upsertConv(peer, peerName, preview, serverTs > 0 ? serverTs : ts, 0);
+        });
+}
+
+void Client::resendFailed(QWidget *row)
+{
+    if (!row || row->property("sendStatus").toInt() != LocalMsg::Failed)
+        return;
+    const qint64 localId = row->property("outgoingId").toLongLong();
+    const qint64 peer = row->property("outgoingPeer").toLongLong();
+    const int msgType = row->property("outgoingType").toInt();
+    const QString content = row->property("outgoingContent").toString();
+    if (localId == 0 || peer <= 0 || content.isEmpty())
+        return;
+    QString peerName = m_currentPeerName;
+    if (auto *item = findConvItem(peer))
+        peerName = item->data(RoleName).toString();
+    sendOutgoing(peer, peerName, msgType > 0 ? msgType : im::MsgType::Text, content,
+                 previewOf(msgType > 0 ? msgType : im::MsgType::Text, content), row, localId);
+}
+
+void Client::sendFile(bool imageOnly)
+{
+    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
+        return;
+    const QString filter = imageOnly
+        ? QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
+        : QString();
+    const QString path = QFileDialog::getOpenFileName(
+        this, imageOnly ? QStringLiteral("选择图片") : QStringLiteral("选择文件"), QString(), filter);
+    if (path.isEmpty())
+        return;
+    QFileInfo info(path);
+    if (info.size() <= 0)
+    {
+        UiHelpers::showToast(this, QStringLiteral("文件无效"), true);
+        return;
+    }
+    if (info.size() > AppConfig::instance().fileMax())
+    {
+        UiHelpers::showToast(this, QStringLiteral("文件不能超过 %1")
+                                       .arg(FileCache::formatSize(AppConfig::instance().fileMax())), true);
+        return;
+    }
+    const QString mime = QMimeDatabase().mimeTypeForFile(path).name();
+    const QString name = info.fileName();
+    const qint64 size = info.size();
+    if (imageOnly && !FileCache::looksLikeImage(mime, name))
+    {
+        UiHelpers::showToast(this, QStringLiteral("请选择图片文件"), true);
+        return;
+    }
+
+    QPointer<Client> self(this);
+    const qint64 peer = m_currentPeerUid;
+    const QString peerName = m_currentPeerName;
+    ImClient::instance().putFile(name, mime, size, [self, path, name, mime, size, peer, peerName](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const qint64 fileId = Proto::jsonI64(reply, QStringLiteral("file_id"));
+        const QString gotMime = reply.value(QStringLiteral("mime")).toString(mime);
+        const QString gotName = reply.value(QStringLiteral("name")).toString(name);
+        const QString url = reply.value(QStringLiteral("url")).toString();
+        if (fileId <= 0 || url.isEmpty())
+        {
+            UiHelpers::showToast(self, QStringLiteral("上传凭证无效"), true);
+            return;
+        }
+        if (!FileCache::instance().rememberPath(fileId, path, gotMime, gotName))
+        {
+            UiHelpers::showToast(self, QStringLiteral("无法保存本地文件"), true);
+            return;
+        }
+        FileCache::instance().bindMeta(fileId, url, gotMime, gotName, size);
+
+        QJsonObject content;
+        content.insert(QStringLiteral("file_id"), fileId);
+        content.insert(QStringLiteral("name"), gotName);
+        content.insert(QStringLiteral("size"), size);
+        content.insert(QStringLiteral("mime"), gotMime);
+        content.insert(QStringLiteral("url"), url);
+        const QString contentText = QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact));
+        const QString preview = FileCache::looksLikeImage(gotMime, gotName)
+            ? QStringLiteral("[图片]")
+            : QStringLiteral("[文件] %1").arg(gotName);
+
+        const qint64 localId = LocalStore::instance().allocLocalId();
+        const qint64 ts = QDateTime::currentMSecsSinceEpoch();
+        self->saveLocalMessage(localId, peer, self->m_uid, peer, im::MsgType::File, contentText, ts,
+                               LocalMsg::Sending);
+        self->m_inflightLocalIds.insert(localId);
+
+        auto *once = new QObject(self);
+        QObject::connect(&FileCache::instance(), &FileCache::uploaded, once,
+                         [self, once, fileId, peer, peerName, contentText, preview, localId](qint64 id) {
+            if (id != fileId)
+                return;
+            once->deleteLater();
+            if (!self)
+                return;
+            QWidget *row = self->findOutgoingRow(localId);
+            self->sendOutgoing(peer, peerName, im::MsgType::File, contentText, preview, row, localId);
+        });
+        QObject::connect(&FileCache::instance(), &FileCache::failed, once,
+                         [self, fileId](qint64 id, const QString &error) {
+            if (id != fileId || !self)
+                return;
+            UiHelpers::showToast(self, error.isEmpty() ? QStringLiteral("上传失败") : error, true);
+        });
+
+        FileCache::instance().startUpload(fileId, reply.value(QStringLiteral("token")).toObject());
+        if (self->isViewingPeer(peer))
+            self->addBubble(true, self->m_nickname, contentText, QStringLiteral("刚刚"),
+                            im::MsgType::File, LocalMsg::Sending, localId, peer);
+        self->upsertConv(peer, peerName, preview, ts, 0);
+    });
+}
+
+void Client::previewChatImage(qint64 fileId, const QString &name, const QString &content)
+{
+    if (fileId <= 0)
+        return;
+    auto show = [this, fileId, name, content]() {
+        const QByteArray data = FileCache::instance().bytes(fileId);
+        if (data.isEmpty())
+            return false;
+        QPixmap pm;
+        if (!pm.loadFromData(data))
+        {
+            UiHelpers::showToast(this, QStringLiteral("无法预览该图片"), true);
+            return true;
+        }
+        ImagePreviewDialog dlg(pm, name, data, this, !content.isEmpty());
+        dlg.exec();
+        if (dlg.wantsForward())
+            forwardPayload(im::MsgType::File, content, QStringLiteral("[图片]"));
+        return true;
+    };
+    if (show())
+        return;
+    UiHelpers::showToast(this, QStringLiteral("正在加载图片"));
+    QPointer<Client> self(this);
+    auto *once = new QObject(this);
+    connect(&FileCache::instance(), &FileCache::ready, once, [self, once, fileId, name, content](qint64 id) {
+        if (id != fileId)
+            return;
+        once->deleteLater();
+        if (!self)
+            return;
+        const QByteArray data = FileCache::instance().bytes(fileId);
+        QPixmap pm;
+        if (data.isEmpty() || !pm.loadFromData(data))
+            return;
+        ImagePreviewDialog dlg(pm, name, data, self, !content.isEmpty());
+        dlg.exec();
+        if (dlg.wantsForward())
+            self->forwardPayload(im::MsgType::File, content, QStringLiteral("[图片]"));
+    });
+    FileCache::instance().ensure(fileId, QJsonDocument::fromJson(content.toUtf8()).object()
+                                             .value(QStringLiteral("url")).toString());
+}
+
+void Client::forwardPayload(int msgType, const QString &content, const QString &preview,
+                            QWidget *host)
+{
+    Q_UNUSED(host);
+    if (content.isEmpty())
+        return;
+    QPointer<Client> self(this);
+    ImClient::instance().friendList([self, msgType, content, preview](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        // 不能在 socket readyRead / 其它模态 exec 里直接再 exec,否则转发框会挂起直到下一次点击。
+        QTimer::singleShot(0, self.data(), [self, msgType, content, preview, list]() {
+            if (!self)
+                return;
+            PickFriendDialog dlg(list, self->m_uid, self, QStringLiteral("转发给"), true);
+            dlg.setWindowModality(Qt::ApplicationModal);
+            dlg.raise();
+            dlg.activateWindow();
+            if (dlg.exec() != QDialog::Accepted)
+                return;
+            const QJsonObject o = dlg.selected();
+            const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+            if (uid <= 0)
+                return;
+            QString name = o.value(QStringLiteral("remark")).toString();
+            if (name.isEmpty())
+                name = o.value(QStringLiteral("nickname")).toString();
+            if (name.isEmpty())
+                name = o.value(QStringLiteral("account")).toString();
+            self->sendChatPayloadTo(uid, name, msgType, content, preview);
+            UiHelpers::showToast(self, QStringLiteral("已转发"));
+        });
+    });
+}
+
+void Client::shareUserCard(const QJsonObject &user, QWidget *host)
+{
+    const qint64 uid = Proto::jsonI64(user, QStringLiteral("uid"));
+    if (uid <= 0)
+        return;
+    QJsonObject content;
+    content.insert(QStringLiteral("uid"), uid);
+    content.insert(QStringLiteral("account"), user.value(QStringLiteral("account")).toString());
+    QString nick = user.value(QStringLiteral("nickname")).toString();
+    if (nick.isEmpty())
+        nick = user.value(QStringLiteral("account")).toString();
+    content.insert(QStringLiteral("nickname"), nick);
+    content.insert(QStringLiteral("signature"), user.value(QStringLiteral("signature")).toString());
+    content.insert(QStringLiteral("avatar_ver"), user.value(QStringLiteral("avatar_ver")).toInt());
+    forwardPayload(im::MsgType::Card,
+                   QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact)),
+                   QStringLiteral("[名片] %1").arg(nick), host);
+}
+
+void Client::openAddFriend()
+{
+    AddFriendDialog dlg(this);
+    dlg.exec();
+    refreshFriendList();
+}
+
+void Client::openFriendRequests()
+{
+    FriendRequestDialog dlg(this);
+    connect(&dlg, &FriendRequestDialog::acceptedFriend, this, [this]() { refreshFriendList(); });
+    dlg.exec();
+    refreshFriendList();
+}
+
+void Client::setPendingCount(int n)
+{
+    m_pendingFriends = n;
+    if (!m_pendingBadge)
+        return;
+    if (n <= 0)
+    {
+        m_pendingBadge->hide();
+        updateTray();
+        return;
+    }
+    m_pendingBadge->setText(n > 99 ? QStringLiteral("99") : QString::number(n));
+    m_pendingBadge->setFixedSize(18, 18);
+    m_pendingBadge->show();
+    updateTray();
+}
+
+void Client::onFriendRequestNotify(const QJsonObject &msg)
+{
+    QString name = msg.value(QStringLiteral("nickname")).toString();
+    if (name.isEmpty())
+        name = msg.value(QStringLiteral("account")).toString();
+    UiHelpers::showToast(this, QStringLiteral("%1 请求添加你为好友").arg(name));
+    refreshFriendList();
+}
+
+void Client::onFriendAcceptedNotify(const QJsonObject &msg)
+{
+    QString name = msg.value(QStringLiteral("nickname")).toString();
+    if (name.isEmpty())
+        name = msg.value(QStringLiteral("account")).toString();
+    UiHelpers::showToast(this, QStringLiteral("%1 已同意你的好友申请").arg(name));
+    refreshFriendList();
+}
+
+int Client::unreadTotal() const
+{
+    int n = 0;
+    if (!m_convList)
+        return 0;
+    for (int i = 0; i < m_convList->count(); ++i)
+        n += m_convList->item(i)->data(RoleUnread).toInt();
+    return n;
+}
+
+void Client::setupTray()
+{
+    if (!QSystemTrayIcon::isSystemTrayAvailable())
+        return;
+
+    auto *menu = new QMenu(this);
+    auto *openAct = menu->addAction(QStringLiteral("打开主面板"));
+    menu->addSeparator();
+    auto *quitAct = menu->addAction(QStringLiteral("退出"));
+    connect(openAct, &QAction::triggered, this, &Client::showFromTray);
+    connect(quitAct, &QAction::triggered, this, &Client::quitFromTray);
+
+    m_tray = new QSystemTrayIcon(this);
+    m_tray->setContextMenu(menu);
+    m_tray->setIcon(UiHelpers::appIcon());
+    m_tray->setToolTip(QStringLiteral("NIM"));
+    m_tray->show();
+    connect(m_tray, &QSystemTrayIcon::activated, this, [this](QSystemTrayIcon::ActivationReason reason) {
+        if (reason == QSystemTrayIcon::Trigger || reason == QSystemTrayIcon::DoubleClick)
+            showFromTray();
+    });
+
+    m_trayPreview = new QWidget(this, Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
+    m_trayPreview->setAttribute(Qt::WA_ShowWithoutActivating);
+    m_trayPreview->setAttribute(Qt::WA_TranslucentBackground, false);
+    m_trayPreview->setObjectName(QStringLiteral("TrayPreview"));
+    m_trayPreview->setFixedWidth(280);
+    m_trayPreview->setStyleSheet(QStringLiteral(
+        "QWidget#TrayPreview { background:#FFFFFF; border:1px solid #E5E5E5; }"));
+    auto *lay = new QVBoxLayout(m_trayPreview);
+    lay->setContentsMargins(6, 8, 6, 8);
+    lay->setSpacing(2);
+    m_trayPreview->hide();
+
+    m_trayHover = new QTimer(this);
+    m_trayHover->setInterval(160);
+    connect(m_trayHover, &QTimer::timeout, this, &Client::pollTrayHover);
+    m_trayHover->start();
+    updateTray();
+}
+
+void Client::updateTray()
+{
+    if (!m_tray)
+        return;
+    const int unread = unreadTotal();
+    m_tray->setIcon(UiHelpers::appIcon());
+
+    QString tip = QStringLiteral("NIM");
+    if (unread > 0)
+        tip = QStringLiteral("NIM  %1 条未读消息").arg(unread);
+    if (m_pendingFriends > 0)
+        tip += QStringLiteral("\n%1 条好友申请").arg(m_pendingFriends);
+    m_tray->setToolTip(tip);
+
+    auto *lay = qobject_cast<QVBoxLayout *>(m_trayPreview ? m_trayPreview->layout() : nullptr);
+    if (!lay)
+        return;
+    while (QLayoutItem *it = lay->takeAt(0))
+    {
+        if (it->widget())
+            it->widget()->deleteLater();
+        delete it;
+    }
+
+    auto addPreviewRow = [&](const QString &name, const QString &preview, int unread,
+                             qint64 peerUid, bool friends) {
+        auto *row = new TrayPreviewRow(m_trayPreview);
+        const QString peerName = name;
+        row->onActivate = [this, peerUid, peerName, friends]() {
+            showFromTray();
+            if (friends)
+                QTimer::singleShot(0, this, &Client::openFriendRequests);
+            else
+                openChat(peerUid, peerName);
+        };
+        auto *h = new QHBoxLayout(row);
+        h->setContentsMargins(8, 6, 8, 6);
+        h->setSpacing(8);
+
+        auto passthrough = [](QWidget *w) {
+            w->setAttribute(Qt::WA_TransparentForMouseEvents);
+        };
+
+        auto *avatar = new QLabel(row);
+        avatar->setFixedSize(32, 32);
+        avatar->setPixmap(UiHelpers::avatarPixmap(name, 32));
+        if (!friends)
+            avatar->setPixmap(peerAvatar(peerUid, name, 32));
+        passthrough(avatar);
+
+        auto *info = new QVBoxLayout();
+        info->setContentsMargins(0, 0, 0, 0);
+        info->setSpacing(1);
+        auto *nameLab = new ElideLabel(name, row);
+        nameLab->setStyleSheet(QStringLiteral("font-size:13px; color:#1A1A1A; background:transparent;"));
+        passthrough(nameLab);
+        auto *prev = new ElideLabel(preview, row);
+        prev->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent;"));
+        passthrough(prev);
+        info->addWidget(nameLab);
+        info->addWidget(prev);
+
+        auto *badge = new QLabel(row);
+        passthrough(badge);
+        badge->setAlignment(Qt::AlignCenter);
+        if (unread > 0)
+        {
+            badge->setObjectName(QStringLiteral("trayUnreadBadge"));
+            badge->setText(unread > 99 ? QStringLiteral("99") : QString::number(unread));
+            badge->setFixedSize(18, 18);
+            badge->setStyleSheet(QStringLiteral(
+                "QLabel#trayUnreadBadge { background:#FA5151; color:#FFFFFF; font-size:10px;"
+                " border:none; border-radius:9px; }"));
+        }
+        else
+        {
+            badge->hide();
+        }
+
+        h->addWidget(avatar, 0, Qt::AlignVCenter);
+        h->addLayout(info, 1);
+        h->addWidget(badge, 0, Qt::AlignVCenter);
+        lay->addWidget(row);
+    };
+
+    auto *title = new QLabel(m_trayPreview);
+    title->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; padding:2px 8px 6px 8px;"));
+    if (unread <= 0 && m_pendingFriends <= 0)
+    {
+        title->setText(QStringLiteral("暂无未读消息"));
+        lay->addWidget(title);
+        m_trayPreview->adjustSize();
+        return;
+    }
+    if (unread > 0)
+    {
+        title->setText(QStringLiteral("%1 条未读消息").arg(unread));
+        lay->addWidget(title);
+    }
+    else
+    {
+        delete title;
+        title = nullptr;
+    }
+
+    int shown = 0;
+    if (m_convList)
+    {
+        for (int i = 0; i < m_convList->count() && shown < 5; ++i)
+        {
+            auto *item = m_convList->item(i);
+            const int n = item->data(RoleUnread).toInt();
+            if (n <= 0)
+                continue;
+            addPreviewRow(item->data(RoleName).toString(),
+                          item->data(RolePreview).toString(),
+                          n,
+                          item->data(RolePeerUid).toLongLong(),
+                          false);
+            ++shown;
+        }
+    }
+    if (unread > shown)
+    {
+        auto *more = new TrayPreviewRow(m_trayPreview);
+        more->setMinimumHeight(28);
+        more->onActivate = [this]() { showFromTray(); };
+        auto *moreLab = new QLabel(QStringLiteral("还有 %1 条未读").arg(unread - shown), more);
+        moreLab->setStyleSheet(QStringLiteral("font-size:12px; color:#B0B0B0; background:transparent;"));
+        moreLab->setAttribute(Qt::WA_TransparentForMouseEvents);
+        auto *moreLay = new QHBoxLayout(more);
+        moreLay->setContentsMargins(8, 4, 8, 4);
+        moreLay->addWidget(moreLab);
+        lay->addWidget(more);
+    }
+    if (m_pendingFriends > 0)
+        addPreviewRow(QStringLiteral("新的朋友"),
+                      QStringLiteral("%1 条好友申请").arg(m_pendingFriends),
+                      m_pendingFriends,
+                      0,
+                      true);
+    m_trayPreview->adjustSize();
+}
+
+void Client::pollTrayHover()
+{
+    if (!m_tray || !m_tray->isVisible() || !m_trayPreview)
+        return;
+    const QRect iconRect = m_tray->geometry();
+    if (!iconRect.isValid() || iconRect.isEmpty())
+        return;
+    const QPoint pos = QCursor::pos();
+    const bool overIcon = iconRect.adjusted(-4, -4, 4, 4).contains(pos);
+    const bool overPreview = m_trayPreview->isVisible() && m_trayPreview->geometry().adjusted(-6, -6, 6, 6).contains(pos);
+    const int unread = unreadTotal();
+    if ((overIcon || overPreview) && (unread > 0 || m_pendingFriends > 0))
+    {
+        QPoint p(iconRect.center().x() - m_trayPreview->width() / 2,
+                 iconRect.top() - m_trayPreview->height() - 8);
+        if (p.y() < 0)
+            p.setY(iconRect.bottom() + 8);
+        if (p.x() < 8)
+            p.setX(8);
+        m_trayPreview->move(p);
+        m_trayPreview->show();
+        m_trayPreview->raise();
+    }
+    else if (m_trayPreview->isVisible())
+    {
+        m_trayPreview->hide();
+    }
+}
+
+void Client::showFromTray()
+{
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    show();
+    raise();
+    activateWindow();
+}
+
+void Client::quitFromTray()
+{
+    m_forceQuit = true;
+    if (m_tray)
+        m_tray->hide();
+    QApplication::quit();
+}
+
+#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
+bool Client::nativeEvent(const QByteArray &eventType, void *message, long *result)
+#else
+bool Client::nativeEvent(const QByteArray &eventType, void *message, qintptr *result)
+#endif
+{
+    if (m_titleBar && m_titleBar->framelessHelper())
+    {
+        if (m_titleBar->framelessHelper()->handleNativeEvent(message, result))
+            return true;
+    }
+    return QMainWindow::nativeEvent(eventType, message, result);
+}
+
+void Client::showEvent(QShowEvent *event)
+{
+    QMainWindow::showEvent(event);
+    if (m_geomRestored)
+        return;
+    m_geomRestored = true;
+    const auto &u = UserSettings::instance();
+    QRect g = u.windowRect();
+    if (g.width() > 0 && g.height() > 0)
+    {
+        if (g.width() < 760)
+            g.setWidth(760);
+        if (g.height() < 500)
+            g.setHeight(500);
+        if (auto *h = m_titleBar ? m_titleBar->framelessHelper() : nullptr)
+            h->setNormalGeometry(g);
+        setGeometry(g);
+    }
+    if (u.windowMaximized())
+        showMaximized();
+}
+
+void Client::changeEvent(QEvent *event)
+{
+    QMainWindow::changeEvent(event);
+    if (event->type() == QEvent::WindowStateChange && m_geomSave)
+        m_geomSave->start();
+}
+
+void Client::closeEvent(QCloseEvent *event)
+{
+    persistWindow();
+    if (!m_loggingOut && !m_forceQuit && m_tray && m_tray->isVisible())
+    {
+        int action = UserSettings::instance().closeAction();
+        if (action <= 0)
+        {
+            const int r = ConfirmDialog::chooseAction(
+                this, QStringLiteral("关闭主窗口"),
+                QStringLiteral("关闭窗口时要最小化到托盘,还是退出程序?\n可在「设置 - 通用」中更改。"),
+                QStringLiteral("退出程序"), QStringLiteral("最小化到托盘"));
+            if (r == 0)
+            {
+                event->ignore();
+                return;
+            }
+            action = (r == 2) ? 1 : 2;
+            UserSettings::instance().setCloseAction(action);
+        }
+        if (action == 1)
+        {
+            event->ignore();
+            hide();
+            if (m_trayPreview)
+                m_trayPreview->hide();
+            return;
+        }
+    }
+    if (m_tray)
+        m_tray->hide();
+    if (m_trayPreview)
+        m_trayPreview->hide();
+    if (m_currentPeerUid > 0)
+        ImClient::instance().setUnread(m_currentPeerUid, 0, {});
+    if (m_forceQuit || !m_loggingOut)
+        QApplication::quit();
+    QMainWindow::closeEvent(event);
+}

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

@@ -0,0 +1,205 @@
+#pragma once
+
+#include <QtWidgets/QMainWindow>
+#include <QPixmap>
+#include <QJsonObject>
+#include <QSet>
+
+class QShowEvent;
+class QCloseEvent;
+class QEvent;
+class QPoint;
+class TitleBar;
+class FramelessHelper;
+class QListWidget;
+class QListWidgetItem;
+class QLabel;
+class QTextEdit;
+class QScrollArea;
+class QWidget;
+class QVBoxLayout;
+class QStackedWidget;
+class QLineEdit;
+class QPushButton;
+class QJsonArray;
+class QSystemTrayIcon;
+class QMenu;
+class QTimer;
+
+class Client : public QMainWindow
+{
+    Q_OBJECT
+
+public:
+    explicit Client(const QString &account, const QString &nickname, QWidget *parent = nullptr);
+    ~Client();
+
+signals:
+    void loggedOut();
+
+protected:
+    void showEvent(QShowEvent *event) override;
+    void closeEvent(QCloseEvent *event) override;
+    void changeEvent(QEvent *event) override;
+    bool eventFilter(QObject *watched, QEvent *event) override;
+#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
+    bool nativeEvent(const QByteArray &eventType, void *message, long *result) override;
+#else
+    bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override;
+#endif
+
+private:
+    void buildUi();
+    QWidget *buildNav();
+    QWidget *buildConversationPane();
+    QWidget *buildChatPane();
+    QWidget *buildFriendPane();
+    void refreshConvList();
+    void refreshFriendList();
+    void applyConvList(const QJsonArray &list);
+    void applyFriendList(const QJsonArray &list);
+    void applyListFilter();
+    void applyHistory(const QJsonArray &list);
+    void selectConversation(int row);
+    void openChat(qint64 peerUid, const QString &name);
+    void onConvMenu(const QPoint &pos);
+    void onFriendMenu(const QPoint &pos);
+    void applyUnread(qint64 peerUid, int unread);
+    void showProfile(qint64 uid);
+    void showFriendCard(qint64 uid);
+    void fillProfilePage(const QJsonObject &user);
+    void persistWindow();
+    void removeLocalConv(qint64 uid);
+    void markPeerRead(qint64 peerUid);
+    void saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
+                          int msgType, const QString &content, qint64 ts, int status = 0);
+    void persistLocalConv(qint64 peerUid, const QString &name, const QString &preview,
+                          qint64 ts, int unread);
+    void loadLocalConvs();
+    bool isViewingPeer(qint64 peerUid) const;
+    void sendCurrentMessage();
+    void sendOutgoing(qint64 peer, const QString &peerName, int msgType,
+                      const QString &content, const QString &preview,
+                      QWidget *existingRow = nullptr, qint64 localId = 0);
+    void resendFailed(QWidget *row);
+    void sendSticker(qint64 packId, const QString &code);
+    void sendFile(bool imageOnly = false);
+    void previewChatImage(qint64 fileId, const QString &name, 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);
+    void forwardPayload(int msgType, const QString &content, const QString &preview,
+                        QWidget *host = nullptr);
+    void shareUserCard(const QJsonObject &user, QWidget *host = nullptr);
+    void refreshAvatar(qint64 uid);
+    int avatarVerOf(qint64 uid) const;
+    QPixmap peerAvatar(qint64 uid, const QString &name, int size) const;
+    QWidget *addBubble(bool mine, const QString &name, const QString &text, const QString &time,
+                       int msgType = 1, int sendStatus = 0, qint64 msgId = 0, qint64 peerUid = 0);
+    QWidget *findOutgoingRow(qint64 localId) const;
+    void setBubbleSendState(QWidget *row, bool failed, bool sending = false);
+    void scrollChatToBottom();
+    void clearBubbles();
+    void upsertConv(qint64 peerUid, const QString &name, const QString &preview,
+                    qint64 ts, int unread);
+    void showSettings();
+    void showSettings(int page);
+    void openAddFriend();
+    void openFriendRequests();
+    void onChangePassword();
+    void onLogout();
+    void onKicked();
+    void onDisconnected();
+    void onConnected();
+    void onConnectFailed(const QString &reason);
+    void startReconnect();
+    void attemptReconnect();
+    void scheduleReconnectRetry();
+    void onReconnectTick();
+    void finishReconnectAttempt(bool ok);
+    void finishReconnectOk();
+    void showReconnectFailed();
+    void hideReconnectOverlay();
+    void updateReconnectUi();
+    QString reconnectPassword() const;
+    void setupReconnectOverlay(QWidget *parent);
+    void onChatNotify(const QJsonObject &msg);
+    void onFriendRequestNotify(const QJsonObject &msg);
+    void onFriendAcceptedNotify(const QJsonObject &msg);
+    void applyNavSelection(int index);
+    void setPendingCount(int n);
+    void setupTray();
+    void updateTray();
+    void showFromTray();
+    void quitFromTray();
+    void pollTrayHover();
+    int unreadTotal() const;
+    QListWidgetItem *findConvItem(qint64 peerUid) const;
+    QString previewOf(int msgType, const QString &content) const;
+
+    QString m_account;
+    QString m_nickname;
+    qint64 m_uid = 0;
+    qint64 m_currentPeerUid = 0;
+    QString m_currentPeerName;
+    bool m_loggingOut = false;
+    bool m_forceQuit = false;
+    bool m_geomRestored = false;
+    bool m_stickChatToBottom = true;
+    TitleBar *m_titleBar = nullptr;
+    QLabel *m_navAvatar = nullptr;
+
+    QPushButton *m_navChat = nullptr;
+    QPushButton *m_navFriends = nullptr;
+    QPushButton *m_navSettings = nullptr;
+    QStackedWidget *m_sideStack = nullptr;
+    QStackedWidget *m_convStack = nullptr;
+    QStackedWidget *m_friendStack = nullptr;
+    QListWidget *m_convList = nullptr;
+    QListWidget *m_friendList = nullptr;
+    QLabel *m_convEmpty = nullptr;
+    QLabel *m_friendEmpty = nullptr;
+    QLineEdit *m_search = nullptr;
+    QPushButton *m_addFriendBtn = nullptr;
+    QPushButton *m_newFriendsBtn = nullptr;
+    QLabel *m_pendingBadge = nullptr;
+    class StickerPanel *m_stickerPanel = nullptr;
+
+    QStackedWidget *m_chatStack = nullptr;
+    QWidget *m_chatEmpty = nullptr;
+    QWidget *m_chatBody = nullptr;
+    QLabel *m_chatTitle = nullptr;
+    QWidget *m_profilePage = nullptr;
+    QLabel *m_profileAvatar = nullptr;
+    QLabel *m_profileTitle = nullptr;
+    QLabel *m_profileRemark = nullptr;
+    QLabel *m_profileNick = nullptr;
+    QLabel *m_profileAccount = nullptr;
+    QLabel *m_profileSign = nullptr;
+    QPushButton *m_profileChatBtn = nullptr;
+    QPushButton *m_profileShareBtn = nullptr;
+    QJsonObject m_profileUser;
+    qint64 m_profileUid = 0;
+    int m_profileAvatarVer = 0;
+    QString m_profileDisplayName;
+    QWidget *m_bubbleHost = nullptr;
+    QVBoxLayout *m_bubbleLay = nullptr;
+    QScrollArea *m_scroll = nullptr;
+    QTextEdit *m_input = nullptr;
+    QSystemTrayIcon *m_tray = nullptr;
+    QWidget *m_trayPreview = nullptr;
+    QTimer *m_trayHover = nullptr;
+    QTimer *m_geomSave = nullptr;
+    QWidget *m_reconnectMask = nullptr;
+    QWidget *m_reconnectSpin = nullptr;
+    QLabel *m_reconnectTitle = nullptr;
+    QLabel *m_reconnectHint = nullptr;
+    QPushButton *m_reconnectBtn = nullptr;
+    QTimer *m_reconnectTimer = nullptr;
+    int m_reconnectAttempt = 0;
+    int m_reconnectWaitLeft = 0;
+    bool m_reconnecting = false;
+    bool m_reconnectInFlight = false;
+    int m_pendingFriends = 0;
+    QSet<qint64> m_inflightLocalIds;
+};

+ 213 - 0
Client/src/mainframe/FriendRequestDialog.cpp

@@ -0,0 +1,213 @@
+#include "FriendRequestDialog.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+#include "ImClient.h"
+#include "Protocol.h"
+#include "AvatarCache.h"
+
+#include <QJsonArray>
+#include <QJsonObject>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QPointer>
+#include <QPushButton>
+#include <QScrollArea>
+#include <QFrame>
+#include <QVBoxLayout>
+
+FriendRequestDialog::FriendRequestDialog(QWidget *parent)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("新的朋友"));
+    setFixedSize(400, 460);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(QStringLiteral("新的朋友"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *scroll = new QScrollArea(this);
+    scroll->setWidgetResizable(true);
+    scroll->setFrameShape(QFrame::NoFrame);
+    auto *host = new QWidget(scroll);
+    host->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    m_listLay = new QVBoxLayout(host);
+    m_listLay->setContentsMargins(16, 12, 16, 16);
+    m_listLay->setSpacing(8);
+    m_empty = new QLabel(QStringLiteral("暂无好友申请"), host);
+    m_empty->setAlignment(Qt::AlignCenter);
+    m_empty->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:13px; padding:40px 0; background:transparent;"));
+    m_listLay->addWidget(m_empty);
+    m_listLay->addStretch();
+    scroll->setWidget(host);
+    root->addWidget(scroll, 1);
+
+    reload();
+}
+
+void FriendRequestDialog::reload()
+{
+    QPointer<FriendRequestDialog> self(this);
+    ImClient::instance().friendRequestList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->rebuild(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void FriendRequestDialog::rebuild(const QJsonArray &list)
+{
+    while (m_listLay->count() > 1)
+    {
+        QLayoutItem *it = m_listLay->takeAt(0);
+        if (it->widget())
+            it->widget()->deleteLater();
+        delete it;
+    }
+    m_empty = new QLabel(QStringLiteral("暂无好友申请"), m_listLay->parentWidget());
+    m_empty->setAlignment(Qt::AlignCenter);
+    m_empty->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:13px; padding:40px 0; background:transparent;"));
+    if (list.isEmpty())
+    {
+        m_listLay->insertWidget(0, m_empty);
+        return;
+    }
+    m_empty->hide();
+    int index = 0;
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        QString name = o.value(QStringLiteral("nickname")).toString();
+        if (name.isEmpty())
+            name = o.value(QStringLiteral("account")).toString();
+        const qint64 fromUid = Proto::jsonI64(o, QStringLiteral("uid"));
+        const int ver = static_cast<int>(Proto::jsonI64(o, QStringLiteral("avatar_ver")));
+        auto *row = new QWidget(m_listLay->parentWidget());
+        auto *lay = new QHBoxLayout(row);
+        lay->setContentsMargins(8, 8, 8, 8);
+        auto *avatar = new QLabel(row);
+        avatar->setFixedSize(40, 40);
+        AvatarCache::instance().applyTo(avatar, fromUid, ver, name, 40);
+        auto *info = new QVBoxLayout();
+        info->setSpacing(2);
+        info->setContentsMargins(0, 0, 0, 0);
+        auto *lab = new QLabel(name, row);
+        lab->setStyleSheet(QStringLiteral("font-size:14px; background:transparent; border:none;"));
+        info->addWidget(lab);
+        const QString msg = o.value(QStringLiteral("message")).toString().trimmed();
+        auto *sub = new QLabel(msg.isEmpty() ? QStringLiteral("请求添加你为好友") : msg, row);
+        sub->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent; border:none;"));
+        info->addWidget(sub);
+        auto *deal = UiHelpers::compactButton(QStringLiteral("处理"), row);
+        lay->addWidget(avatar, 0, Qt::AlignVCenter);
+        lay->addLayout(info, 1);
+        lay->addWidget(deal, 0, Qt::AlignVCenter);
+        connect(deal, &QPushButton::clicked, this, [this, o]() { openCard(o); });
+        m_listLay->insertWidget(index++, row);
+    }
+}
+
+void FriendRequestDialog::openCard(const QJsonObject &user)
+{
+    const qint64 id = Proto::jsonI64(user, QStringLiteral("id"));
+    QString name = user.value(QStringLiteral("nickname")).toString();
+    const QString account = user.value(QStringLiteral("account")).toString();
+    if (name.isEmpty())
+        name = account;
+    const QString message = user.value(QStringLiteral("message")).toString().trimmed();
+    const qint64 fromUid = Proto::jsonI64(user, QStringLiteral("uid"));
+    const int ver = static_cast<int>(Proto::jsonI64(user, QStringLiteral("avatar_ver")));
+
+    QDialog card(this);
+    card.setObjectName(QStringLiteral("ChromeWindow"));
+    card.setWindowTitle(QStringLiteral("好友申请"));
+    card.setFixedSize(360, 400);
+    UiHelpers::setupFramelessDialog(&card);
+
+    auto *root = new QVBoxLayout(&card);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(&card);
+    bar->setTitle(QStringLiteral("好友申请"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(&card, false);
+    root->addWidget(bar);
+
+    auto *page = new QWidget(&card);
+    auto *lay = new QVBoxLayout(page);
+    lay->setContentsMargins(28, 20, 28, 22);
+    lay->setSpacing(8);
+
+    auto *avatar = new QLabel(page);
+    avatar->setFixedSize(64, 64);
+    AvatarCache::instance().applyTo(avatar, fromUid, ver, name, 64);
+    auto *nameLab = new QLabel(name, page);
+    nameLab->setAlignment(Qt::AlignCenter);
+    nameLab->setStyleSheet(QStringLiteral("font-size:16px; color:#1A1A1A; background:transparent; border:none;"));
+    auto *accLab = new QLabel(QStringLiteral("账号  %1").arg(account), page);
+    accLab->setAlignment(Qt::AlignCenter);
+    accLab->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent; border:none;"));
+
+    auto *verifyTitle = new QLabel(QStringLiteral("验证信息"), page);
+    verifyTitle->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent; border:none;"));
+    auto *verify = new QLabel(message.isEmpty() ? QStringLiteral("对方未填写验证信息") : message, page);
+    verify->setWordWrap(true);
+    verify->setStyleSheet(QStringLiteral(
+        "QLabel { font-size:13px; color:#1A1A1A; background:#F7F7F7; border:none; padding:10px 12px; }"));
+
+    auto *btns = new QHBoxLayout();
+    btns->setSpacing(10);
+    auto *no = UiHelpers::compactButton(QStringLiteral("拒绝"), page);
+    auto *ok = UiHelpers::compactButton(QStringLiteral("同意"), page, true);
+    btns->addStretch();
+    btns->addWidget(no);
+    btns->addWidget(ok);
+
+    lay->addWidget(avatar, 0, Qt::AlignHCenter);
+    lay->addWidget(nameLab);
+    lay->addWidget(accLab);
+    lay->addSpacing(10);
+    lay->addWidget(verifyTitle);
+    lay->addWidget(verify);
+    lay->addStretch();
+    lay->addLayout(btns);
+    root->addWidget(page, 1);
+
+    int action = 0;
+    connect(ok, &QPushButton::clicked, &card, [&]() { action = 1; card.accept(); });
+    connect(no, &QPushButton::clicked, &card, [&]() { action = 2; card.accept(); });
+    if (card.exec() == QDialog::Accepted && action > 0)
+        handle(id, action);
+}
+
+void FriendRequestDialog::handle(qint64 id, int action)
+{
+    QPointer<FriendRequestDialog> self(this);
+    ImClient::instance().handleFriendRequest(id, action, [self, action](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        if (action == 1)
+            emit self->acceptedFriend();
+        self->reload();
+    });
+}

+ 28 - 0
Client/src/mainframe/FriendRequestDialog.h

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <QDialog>
+#include <QJsonArray>
+#include <QJsonObject>
+
+class QVBoxLayout;
+class QLabel;
+
+class FriendRequestDialog : public QDialog
+{
+    Q_OBJECT
+
+public:
+    explicit FriendRequestDialog(QWidget *parent = nullptr);
+
+signals:
+    void acceptedFriend();
+
+private:
+    void reload();
+    void handle(qint64 id, int action);
+    void rebuild(const QJsonArray &list);
+    void openCard(const QJsonObject &user);
+
+    QVBoxLayout *m_listLay = nullptr;
+    QLabel *m_empty = nullptr;
+};

+ 118 - 0
Client/src/mainframe/PickFriendDialog.cpp

@@ -0,0 +1,118 @@
+#include "PickFriendDialog.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+#include "AvatarCache.h"
+#include "Protocol.h"
+
+#include <QFrame>
+#include <QHBoxLayout>
+#include <QJsonArray>
+#include <QJsonObject>
+#include <QJsonValue>
+#include <QLabel>
+#include <QList>
+#include <QListWidget>
+#include <QVBoxLayout>
+
+PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid, QWidget *parent,
+                                   const QString &title, bool includeFileHelper)
+    : QDialog(parent)
+{
+    const QString cap = title.isEmpty() ? QStringLiteral("选择好友") : title;
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(cap);
+    setFixedSize(360, 480);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(cap);
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *list = new QListWidget(this);
+    list->setFrameShape(QFrame::NoFrame);
+    list->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#FFFFFF; border:none; }"
+        "QListWidget::item { height:52px; }"
+        "QListWidget::item:selected { background:#EDEDED; }"
+        "QListWidget::item:hover { background:#F5F5F5; }"));
+    root->addWidget(list, 1);
+
+    QList<QJsonObject> rows;
+    QJsonObject helper;
+    for (const QJsonValue &v : friends)
+    {
+        const QJsonObject o = v.toObject();
+        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+        if (uid <= 0 || uid == excludeUid)
+            continue;
+        if (uid == im::kFileHelperUid)
+        {
+            if (includeFileHelper)
+                helper = o;
+            continue;
+        }
+        rows.append(o);
+    }
+    if (includeFileHelper)
+    {
+        if (Proto::jsonI64(helper, QStringLiteral("uid")) <= 0)
+        {
+            helper.insert(QStringLiteral("uid"), static_cast<qint64>(im::kFileHelperUid));
+            helper.insert(QStringLiteral("nickname"), QStringLiteral("文件传输助手"));
+            helper.insert(QStringLiteral("account"), QStringLiteral("filehelper"));
+        }
+        rows.prepend(helper);
+    }
+
+    for (const QJsonObject &o : rows)
+    {
+        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+        const QString account = o.value(QStringLiteral("account")).toString();
+        const QString nick = o.value(QStringLiteral("nickname")).toString();
+        const QString remark = o.value(QStringLiteral("remark")).toString();
+        QString name = remark.isEmpty() ? nick : remark;
+        if (name.isEmpty())
+            name = account;
+        const int ver = static_cast<int>(Proto::jsonI64(o, QStringLiteral("avatar_ver")));
+
+        auto *item = new QListWidgetItem(list);
+        item->setData(Qt::UserRole, o);
+        item->setSizeHint(QSize(200, 52));
+
+        auto *row = new QWidget(list);
+        auto *lay = new QHBoxLayout(row);
+        lay->setContentsMargins(14, 8, 14, 8);
+        lay->setSpacing(10);
+        auto *avatar = new QLabel(row);
+        avatar->setFixedSize(36, 36);
+        AvatarCache::instance().applyTo(avatar, uid, ver, name, 36);
+        auto *lab = new QLabel(name, row);
+        lab->setStyleSheet(QStringLiteral("font-size:14px; color:#191919; background:transparent;"));
+        lay->addWidget(avatar);
+        lay->addWidget(lab, 1);
+        list->setItemWidget(item, row);
+    }
+
+    if (list->count() <= 0)
+    {
+        auto *empty = new QLabel(QStringLiteral("暂无好友"), this);
+        empty->setAlignment(Qt::AlignCenter);
+        empty->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:13px; padding:40px 0; background:#FFFFFF;"));
+        root->addWidget(empty, 1);
+        list->hide();
+    }
+
+    connect(list, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) {
+        if (!item)
+            return;
+        m_selected = item->data(Qt::UserRole).toJsonObject();
+        accept();
+    });
+}

+ 17 - 0
Client/src/mainframe/PickFriendDialog.h

@@ -0,0 +1,17 @@
+#pragma once
+
+#include <QDialog>
+#include <QJsonArray>
+#include <QJsonObject>
+
+class PickFriendDialog : public QDialog
+{
+public:
+    PickFriendDialog(const QJsonArray &friends, qint64 excludeUid, QWidget *parent = nullptr,
+                     const QString &title = QStringLiteral("选择好友"),
+                     bool includeFileHelper = false);
+    QJsonObject selected() const { return m_selected; }
+
+private:
+    QJsonObject m_selected;
+};

BIN
Client/src/mainframe/SettingsDialog.cpp


BIN
Client/src/mainframe/SettingsDialog.h


+ 1 - 0
Client/src/model/.gitkeep

@@ -0,0 +1 @@
+

+ 1 - 0
Client/src/network/.gitkeep

@@ -0,0 +1 @@
+

+ 604 - 0
Client/src/network/HttpFetch.cpp

@@ -0,0 +1,604 @@
+#include "HttpFetch.h"
+#include "Logger.h"
+
+#include <QDateTime>
+#include <QFile>
+#include <QFileInfo>
+#include <QIODevice>
+#include <QMap>
+#include <QPointer>
+#include <QStringList>
+#include <QUrl>
+#include <QtGlobal>
+
+#include <thread>
+
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+#include <winhttp.h>
+
+#pragma comment(lib, "winhttp.lib")
+
+namespace
+{
+    QString winError(DWORD code)
+    {
+        wchar_t *buf = nullptr;
+        const DWORD n = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM
+                                           | FORMAT_MESSAGE_IGNORE_INSERTS,
+                                       nullptr, code, 0, reinterpret_cast<LPWSTR>(&buf), 0, nullptr);
+        QString text = n > 0 && buf ? QString::fromWCharArray(buf).trimmed()
+                                    : QStringLiteral("winhttp error %1").arg(code);
+        if (buf)
+            LocalFree(buf);
+        return text;
+    }
+
+    bool cancelled(const HttpFetch::CancelFlag &flag)
+    {
+        return flag && flag->load();
+    }
+
+    bool paused(const HttpFetch::CancelFlag &flag)
+    {
+        return flag && flag->load();
+    }
+
+    bool parseUrl(const QString &urlStr, bool *https, INTERNET_PORT *port, std::wstring *host,
+                  std::wstring *path, QString *err)
+    {
+        const QUrl url(urlStr);
+        if (!url.isValid() || url.host().isEmpty())
+        {
+            *err = QStringLiteral("invalid url");
+            return false;
+        }
+        *https = url.scheme().compare(QLatin1String("https"), Qt::CaseInsensitive) == 0;
+        *port = static_cast<INTERNET_PORT>(
+            url.port(*https ? INTERNET_DEFAULT_HTTPS_PORT : INTERNET_DEFAULT_HTTP_PORT));
+        QString object = url.path(QUrl::FullyEncoded);
+        if (object.isEmpty())
+            object = QLatin1String("/");
+        const QString query = url.query(QUrl::FullyEncoded);
+        if (!query.isEmpty())
+            object += QLatin1Char('?') + query;
+        *host = url.host().toStdWString();
+        *path = object.toStdWString();
+        return true;
+    }
+
+    HINTERNET openRequest(const QString &urlStr, const wchar_t *verb, QString *err,
+                          HINTERNET *outSession, HINTERNET *outConnect)
+    {
+        bool https = false;
+        INTERNET_PORT port = 0;
+        std::wstring host, path;
+        if (!parseUrl(urlStr, &https, &port, &host, &path, err))
+            return nullptr;
+
+        HINTERNET session = WinHttpOpen(L"NIM/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
+                                        WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
+        if (!session)
+        {
+            *err = winError(GetLastError());
+            return nullptr;
+        }
+        WinHttpSetTimeouts(session, 15000, 15000, 15000, 60000);
+
+        DWORD protocols = WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_2;
+#ifdef WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_3
+        protocols |= WINHTTP_FLAG_SECURE_PROTOCOL_TLS1_3;
+#endif
+        WinHttpSetOption(session, WINHTTP_OPTION_SECURE_PROTOCOLS, &protocols, sizeof(protocols));
+
+        HINTERNET connect = WinHttpConnect(session, host.c_str(), port, 0);
+        if (!connect)
+        {
+            *err = winError(GetLastError());
+            WinHttpCloseHandle(session);
+            return nullptr;
+        }
+
+        DWORD flags = https ? WINHTTP_FLAG_SECURE : 0;
+        HINTERNET request = WinHttpOpenRequest(connect, verb, path.c_str(), nullptr,
+                                               WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags);
+        if (!request)
+        {
+            *err = winError(GetLastError());
+            WinHttpCloseHandle(connect);
+            WinHttpCloseHandle(session);
+            return nullptr;
+        }
+        *outSession = session;
+        *outConnect = connect;
+        return request;
+    }
+
+    void closeAll(HINTERNET request, HINTERNET connect, HINTERNET session)
+    {
+        if (request)
+            WinHttpCloseHandle(request);
+        if (connect)
+            WinHttpCloseHandle(connect);
+        if (session)
+            WinHttpCloseHandle(session);
+    }
+
+    DWORD queryStatus(HINTERNET request)
+    {
+        DWORD status = 0;
+        DWORD statusSize = sizeof(status);
+        if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
+                                 WINHTTP_HEADER_NAME_BY_INDEX, &status, &statusSize, WINHTTP_NO_HEADER_INDEX))
+            return 0;
+        return status;
+    }
+
+    qint64 parseContentRangeTotal(HINTERNET request, qint64 *startOut)
+    {
+        wchar_t buf[128];
+        DWORD sz = sizeof(buf);
+        if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_RANGE, WINHTTP_HEADER_NAME_BY_INDEX,
+                                 buf, &sz, WINHTTP_NO_HEADER_INDEX))
+            return -1;
+        const QString s = QString::fromWCharArray(buf);
+        const int slash = s.lastIndexOf(QLatin1Char('/'));
+        const int dash = s.indexOf(QLatin1Char('-'));
+        const int sp = s.indexOf(QLatin1Char(' '));
+        if (slash < 0)
+            return -1;
+        bool ok = false;
+        const qint64 total = s.mid(slash + 1).toLongLong(&ok);
+        if (startOut && sp >= 0 && dash > sp)
+        {
+            bool sok = false;
+            const qint64 start = s.mid(sp + 1, dash - sp - 1).toLongLong(&sok);
+            if (sok)
+                *startOut = start;
+        }
+        return ok ? total : -1;
+    }
+
+    qint64 queryContentLength(HINTERNET request)
+    {
+        DWORD len = 0;
+        DWORD sz = sizeof(len);
+        if (!WinHttpQueryHeaders(request, WINHTTP_QUERY_CONTENT_LENGTH | WINHTTP_QUERY_FLAG_NUMBER,
+                                 WINHTTP_HEADER_NAME_BY_INDEX, &len, &sz, WINHTTP_NO_HEADER_INDEX))
+            return -1;
+        return static_cast<qint64>(len);
+    }
+
+    bool winHttpGet(const QString &urlStr, QByteArray *out, QString *err)
+    {
+        HINTERNET session = nullptr, connect = nullptr;
+        HINTERNET request = openRequest(urlStr, L"GET", err, &session, &connect);
+        if (!request)
+            return false;
+
+        BOOL sent = WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
+                                       WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
+        if (!sent || !WinHttpReceiveResponse(request, nullptr))
+        {
+            *err = winError(GetLastError());
+            closeAll(request, connect, session);
+            return false;
+        }
+
+        const DWORD status = queryStatus(request);
+        if (status < 200 || status >= 300)
+        {
+            *err = QStringLiteral("http %1").arg(status);
+            closeAll(request, connect, session);
+            return false;
+        }
+
+        QByteArray body;
+        for (;;)
+        {
+            DWORD avail = 0;
+            if (!WinHttpQueryDataAvailable(request, &avail))
+            {
+                *err = winError(GetLastError());
+                closeAll(request, connect, session);
+                return false;
+            }
+            if (avail == 0)
+                break;
+            const int offset = body.size();
+            body.resize(offset + static_cast<int>(avail));
+            DWORD read = 0;
+            if (!WinHttpReadData(request, body.data() + offset, avail, &read))
+            {
+                *err = winError(GetLastError());
+                closeAll(request, connect, session);
+                return false;
+            }
+            body.resize(offset + static_cast<int>(read));
+            if (read == 0)
+                break;
+        }
+
+        closeAll(request, connect, session);
+        *out = body;
+        return true;
+    }
+
+    bool winHttpDownload(const QString &urlStr, const QString &destPath, qint64 resumeFrom,
+                         qint64 expectedTotal, const HttpFetch::CancelFlag &cancel,
+                         const std::function<void(qint64, qint64)> &onBytes, QString *err)
+    {
+        if (cancelled(cancel))
+        {
+            *err = QStringLiteral("cancelled");
+            return false;
+        }
+
+        HINTERNET session = nullptr, connect = nullptr;
+        HINTERNET request = openRequest(urlStr, L"GET", err, &session, &connect);
+        if (!request)
+            return false;
+
+        std::wstring hdr;
+        if (resumeFrom > 0)
+            hdr = L"Range: bytes=" + std::to_wstring(resumeFrom) + L"-\r\n";
+
+        BOOL sent = WinHttpSendRequest(request, resumeFrom > 0 ? hdr.c_str() : WINHTTP_NO_ADDITIONAL_HEADERS,
+                                       resumeFrom > 0 ? static_cast<DWORD>(-1) : 0,
+                                       WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
+        if (!sent || !WinHttpReceiveResponse(request, nullptr))
+        {
+            *err = winError(GetLastError());
+            closeAll(request, connect, session);
+            return false;
+        }
+        if (cancelled(cancel))
+        {
+            *err = QStringLiteral("cancelled");
+            closeAll(request, connect, session);
+            return false;
+        }
+
+        const DWORD status = queryStatus(request);
+        if (status == 416)
+        {
+            closeAll(request, connect, session);
+            if (onBytes && expectedTotal > 0)
+                onBytes(expectedTotal, expectedTotal);
+            return true;
+        }
+        if (status != 200 && status != 206)
+        {
+            *err = QStringLiteral("http %1").arg(status);
+            closeAll(request, connect, session);
+            return false;
+        }
+
+        qint64 rangeStart = resumeFrom;
+        qint64 total = parseContentRangeTotal(request, &rangeStart);
+        const qint64 contentLen = queryContentLength(request);
+        if (total <= 0 && status == 206 && contentLen >= 0)
+            total = rangeStart + contentLen;
+        if (total <= 0 && status == 200 && contentLen >= 0)
+            total = contentLen;
+        if (total <= 0)
+            total = expectedTotal;
+
+        const bool append = (status == 206);
+        qint64 received = append ? resumeFrom : 0;
+        if (status == 200)
+            received = 0;
+
+        QFile file(destPath);
+        QIODevice::OpenMode mode = QIODevice::WriteOnly;
+        if (append && resumeFrom > 0)
+            mode |= QIODevice::Append;
+        else
+            mode |= QIODevice::Truncate;
+        if (!file.open(mode))
+        {
+            *err = QStringLiteral("无法写入临时文件");
+            closeAll(request, connect, session);
+            return false;
+        }
+        if (append)
+            received = file.size();
+
+        if (onBytes)
+            onBytes(received, total);
+
+        DWORD lastTick = GetTickCount();
+        qint64 lastPosted = received;
+        for (;;)
+        {
+            if (cancelled(cancel))
+            {
+                *err = QStringLiteral("cancelled");
+                file.close();
+                closeAll(request, connect, session);
+                return false;
+            }
+            DWORD avail = 0;
+            if (!WinHttpQueryDataAvailable(request, &avail))
+            {
+                *err = winError(GetLastError());
+                file.close();
+                closeAll(request, connect, session);
+                return false;
+            }
+            if (avail == 0)
+                break;
+            QByteArray buf;
+            buf.resize(static_cast<int>(avail));
+            DWORD read = 0;
+            if (!WinHttpReadData(request, buf.data(), avail, &read))
+            {
+                *err = winError(GetLastError());
+                file.close();
+                closeAll(request, connect, session);
+                return false;
+            }
+            if (read == 0)
+                break;
+            buf.resize(static_cast<int>(read));
+            if (file.write(buf) != buf.size())
+            {
+                *err = QStringLiteral("写入失败");
+                file.close();
+                closeAll(request, connect, session);
+                return false;
+            }
+            received += static_cast<qint64>(read);
+            const DWORD now = GetTickCount();
+            if (onBytes && (now - lastTick >= 1000 || received - lastPosted >= 512 * 1024 || received == total))
+            {
+                onBytes(received, total);
+                lastTick = now;
+                lastPosted = received;
+            }
+        }
+
+        file.flush();
+        file.close();
+        closeAll(request, connect, session);
+        if (onBytes)
+            onBytes(received, total > 0 ? total : received);
+        return true;
+    }
+
+    bool winHttpPut(const QString &urlStr, const QString &filePath, const QMap<QString, QString> &headers,
+                    const HttpFetch::CancelFlag &cancel, const HttpFetch::CancelFlag &pause,
+                    const std::function<void(qint64, qint64)> &onBytes,
+                    QString *err)
+    {
+        if (cancelled(cancel))
+        {
+            *err = QStringLiteral("cancelled");
+            return false;
+        }
+
+        QFile file(filePath);
+        if (!file.open(QIODevice::ReadOnly))
+        {
+            *err = QStringLiteral("无法读取文件");
+            return false;
+        }
+        const qint64 total = file.size();
+        if (total <= 0 || total > static_cast<qint64>(0x7fffffff))
+        {
+            *err = QStringLiteral("文件大小无效");
+            return false;
+        }
+
+        HINTERNET session = nullptr, connect = nullptr;
+        HINTERNET request = openRequest(urlStr, L"PUT", err, &session, &connect);
+        if (!request)
+            return false;
+        WinHttpSetTimeouts(session, 15000, 15000, 0, 120000);
+        DWORD disable = WINHTTP_DISABLE_COOKIES | WINHTTP_DISABLE_REDIRECTS;
+        WinHttpSetOption(request, WINHTTP_OPTION_DISABLE_FEATURE, &disable, sizeof(disable));
+
+        QStringList lines;
+        for (auto it = headers.constBegin(); it != headers.constEnd(); ++it)
+        {
+            if (it.key().compare(QLatin1String("Content-Length"), Qt::CaseInsensitive) == 0)
+                continue;
+            if (it.value().isEmpty())
+                continue;
+            lines.append(it.key() + QStringLiteral(": ") + it.value());
+        }
+        if (!lines.isEmpty())
+        {
+            const std::wstring hdr = lines.join(QStringLiteral("\r\n")).toStdWString();
+            if (!WinHttpAddRequestHeaders(request, hdr.c_str(), static_cast<DWORD>(-1),
+                                          WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE))
+            {
+                *err = winError(GetLastError());
+                closeAll(request, connect, session);
+                return false;
+            }
+        }
+
+        BOOL sent = WinHttpSendRequest(request, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
+                                       WINHTTP_NO_REQUEST_DATA, 0, static_cast<DWORD>(total), 0);
+        if (!sent)
+        {
+            *err = winError(GetLastError());
+            closeAll(request, connect, session);
+            return false;
+        }
+
+        qint64 sentBytes = 0;
+        if (onBytes)
+            onBytes(0, total);
+        QByteArray buf;
+        buf.resize(64 * 1024);
+        DWORD lastTick = GetTickCount();
+        qint64 lastPosted = 0;
+        while (sentBytes < total)
+        {
+            bool postedPause = false;
+            while (paused(pause) && !cancelled(cancel))
+            {
+                if (!postedPause && onBytes)
+                {
+                    onBytes(sentBytes, total);
+                    postedPause = true;
+                }
+                Sleep(50);
+            }
+            if (cancelled(cancel))
+            {
+                if (onBytes)
+                    onBytes(sentBytes, total);
+                *err = QStringLiteral("cancelled");
+                closeAll(request, connect, session);
+                return false;
+            }
+            const int want = static_cast<int>(qMin(qint64(buf.size()), total - sentBytes));
+            const int n = file.read(buf.data(), want);
+            if (n <= 0)
+            {
+                *err = QStringLiteral("读取文件失败");
+                closeAll(request, connect, session);
+                return false;
+            }
+            DWORD written = 0;
+            if (!WinHttpWriteData(request, buf.constData(), static_cast<DWORD>(n), &written)
+                || written != static_cast<DWORD>(n))
+            {
+                *err = winError(GetLastError());
+                closeAll(request, connect, session);
+                return false;
+            }
+            sentBytes += written;
+            const DWORD now = GetTickCount();
+            if (onBytes && (now - lastTick >= 1000 || sentBytes - lastPosted >= 512 * 1024
+                            || sentBytes == total))
+            {
+                onBytes(sentBytes, total);
+                lastTick = now;
+                lastPosted = sentBytes;
+            }
+        }
+
+        if (!WinHttpReceiveResponse(request, nullptr))
+        {
+            *err = winError(GetLastError());
+            closeAll(request, connect, session);
+            return false;
+        }
+        const DWORD status = queryStatus(request);
+        closeAll(request, connect, session);
+        if (status != 200 && status != 204)
+        {
+            *err = QStringLiteral("http %1").arg(status);
+            return false;
+        }
+        if (onBytes)
+            onBytes(total, total);
+        return true;
+    }
+}
+
+HttpFetch &HttpFetch::instance()
+{
+    static HttpFetch s;
+    return s;
+}
+
+HttpFetch::HttpFetch(QObject *parent)
+    : QObject(parent)
+{
+}
+
+void HttpFetch::get(const QString &url, Callback cb)
+{
+    if (url.isEmpty())
+    {
+        if (cb)
+            cb(false, {}, QStringLiteral("empty url"));
+        return;
+    }
+
+    QPointer<HttpFetch> self(this);
+    std::thread([self, url, cb]() {
+        QByteArray data;
+        QString error;
+        const bool ok = winHttpGet(url, &data, &error);
+        if (!ok)
+            LOG_WARN(QStringLiteral("http get %1: %2").arg(url, error));
+        if (!self)
+            return;
+        QMetaObject::invokeMethod(self.data(), [self, cb, ok, data, error]() {
+            if (self && cb)
+                cb(ok, data, error);
+        }, Qt::QueuedConnection);
+    }).detach();
+}
+
+void HttpFetch::download(const QString &url, const QString &destPath, qint64 resumeFrom,
+                         qint64 expectedTotal, CancelFlag cancel, ProgressCb progress, FinishCb done)
+{
+    if (url.isEmpty() || destPath.isEmpty())
+    {
+        if (done)
+            done(false, QStringLiteral("empty url"));
+        return;
+    }
+
+    QPointer<HttpFetch> self(this);
+    std::thread([self, url, destPath, resumeFrom, expectedTotal, cancel, progress, done]() {
+        auto postProgress = [self, progress](qint64 rec, qint64 tot) {
+            if (!self || !progress)
+                return;
+            QMetaObject::invokeMethod(self.data(), [self, progress, rec, tot]() {
+                if (self && progress)
+                    progress(rec, tot);
+            }, Qt::QueuedConnection);
+        };
+        QString error;
+        const bool ok = winHttpDownload(url, destPath, resumeFrom, expectedTotal, cancel, postProgress, &error);
+        if (!ok && error != QLatin1String("cancelled"))
+            LOG_WARN(QStringLiteral("http download %1: %2").arg(url, error));
+        if (!self)
+            return;
+        QMetaObject::invokeMethod(self.data(), [self, done, ok, error]() {
+            if (self && done)
+                done(ok, error);
+        }, Qt::QueuedConnection);
+    }).detach();
+}
+
+void HttpFetch::put(const QString &url, const QString &filePath, const QMap<QString, QString> &headers,
+                    CancelFlag cancel, ProgressCb progress, FinishCb done, CancelFlag pause)
+{
+    if (url.isEmpty() || filePath.isEmpty())
+    {
+        if (done)
+            done(false, QStringLiteral("empty url"));
+        return;
+    }
+
+    QPointer<HttpFetch> self(this);
+    std::thread([self, url, filePath, headers, cancel, pause, progress, done]() {
+        auto postProgress = [self, progress](qint64 rec, qint64 tot) {
+            if (!self || !progress)
+                return;
+            QMetaObject::invokeMethod(self.data(), [self, progress, rec, tot]() {
+                if (self && progress)
+                    progress(rec, tot);
+            }, Qt::QueuedConnection);
+        };
+        QString error;
+        const bool ok = winHttpPut(url, filePath, headers, cancel, pause, postProgress, &error);
+        if (!ok && error != QLatin1String("cancelled"))
+            LOG_WARN(QStringLiteral("http put %1: %2").arg(url, error));
+        if (!self)
+            return;
+        QMetaObject::invokeMethod(self.data(), [self, done, ok, error]() {
+            if (self && done)
+                done(ok, error);
+        }, Qt::QueuedConnection);
+    }).detach();
+}

+ 29 - 0
Client/src/network/HttpFetch.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <QByteArray>
+#include <QMap>
+#include <QObject>
+#include <QString>
+#include <atomic>
+#include <functional>
+#include <memory>
+
+class HttpFetch : public QObject
+{
+public:
+    using Callback = std::function<void(bool ok, const QByteArray &data, const QString &error)>;
+    using ProgressCb = std::function<void(qint64 received, qint64 total)>;
+    using FinishCb = std::function<void(bool ok, const QString &error)>;
+    using CancelFlag = std::shared_ptr<std::atomic<bool>>;
+
+    static HttpFetch &instance();
+
+    void get(const QString &url, Callback cb);
+    void download(const QString &url, const QString &destPath, qint64 resumeFrom, qint64 expectedTotal,
+                  CancelFlag cancel, ProgressCb progress, FinishCb done);
+    void put(const QString &url, const QString &filePath, const QMap<QString, QString> &headers,
+             CancelFlag cancel, ProgressCb progress, FinishCb done, CancelFlag pause = CancelFlag());
+
+private:
+    explicit HttpFetch(QObject *parent = nullptr);
+};

+ 652 - 0
Client/src/network/ImClient.cpp

@@ -0,0 +1,652 @@
+#include "ImClient.h"
+#include "AppConfig.h"
+#include "Logger.h"
+
+#include <QApplication>
+#include <QJsonDocument>
+#include <QJsonObject>
+#include <QStringList>
+#include <QtNetwork/QAbstractSocket>
+#include <QtNetwork/QTcpSocket>
+#include <QTimer>
+
+namespace
+{
+    QByteArray toBody(const QJsonObject &obj)
+    {
+        if (obj.isEmpty())
+            return QByteArray();
+        return QJsonDocument(obj).toJson(QJsonDocument::Compact);
+    }
+
+    QJsonObject parseBody(const QByteArray &body)
+    {
+        if (body.isEmpty())
+            return QJsonObject();
+        const QJsonDocument doc = QJsonDocument::fromJson(body);
+        return doc.isObject() ? doc.object() : QJsonObject();
+    }
+
+    QString redactJson(const QJsonObject &obj)
+    {
+        if (obj.isEmpty())
+            return QString();
+        QJsonObject copy = obj;
+        const QStringList secrets = {
+            QStringLiteral("password"),
+            QStringLiteral("old_password"),
+            QStringLiteral("sms_code")
+        };
+        for (const QString &key : secrets)
+        {
+            if (copy.contains(key) && !copy.value(key).toString().isEmpty())
+                copy.insert(key, QStringLiteral("***"));
+        }
+        return QString::fromUtf8(QJsonDocument(copy).toJson(QJsonDocument::Indented)).trimmed();
+    }
+
+    QString packetDump(const char *dir, quint16 type, quint16 cmd, quint32 seq,
+                       const QJsonObject &body)
+    {
+        QString text = QStringLiteral("%1 %2/%3(%4/%5) seq=%6")
+                           .arg(QLatin1String(dir))
+                           .arg(QLatin1String(Proto::typeName(type)))
+                           .arg(QLatin1String(Proto::cmdName(cmd)))
+                           .arg(type)
+                           .arg(cmd)
+                           .arg(seq);
+        const QString json = redactJson(body);
+        if (json.isEmpty())
+            return text;
+        return text + QLatin1Char('\n') + json;
+    }
+}
+
+ImClient &ImClient::instance()
+{
+    static ImClient *client = nullptr;
+    if (!client)
+        client = new ImClient(qApp);
+    return *client;
+}
+
+ImClient::ImClient(QObject *parent)
+    : QObject(parent)
+{
+    const auto &cfg = AppConfig::instance();
+    m_codec = new Proto::PacketCodec(cfg.magic(), cfg.maxPacket());
+    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);
+    connect(m_socket, &QTcpSocket::readyRead, this, &ImClient::onReadyRead);
+    connect(m_socket, QOverload<QAbstractSocket::SocketError>::of(&QAbstractSocket::error),
+            this, &ImClient::onSocketError);
+    connect(m_heartbeat, &QTimer::timeout, this, &ImClient::onHeartbeat);
+}
+
+ImClient::~ImClient()
+{
+    delete m_codec;
+    m_codec = nullptr;
+}
+
+bool ImClient::isConnected() const
+{
+    return m_socket && m_socket->state() == QAbstractSocket::ConnectedState;
+}
+
+void ImClient::connectToServer()
+{
+    if (!m_socket)
+        return;
+    const auto state = m_socket->state();
+    if (state == QAbstractSocket::ConnectedState || state == QAbstractSocket::ConnectingState)
+        return;
+
+    const auto &cfg = AppConfig::instance();
+    m_intentionalClose = false;
+    LOG_INFO(QStringLiteral("connecting %1:%2").arg(cfg.host()).arg(cfg.port()));
+    m_socket->connectToHost(cfg.host(), cfg.port());
+    QTimer::singleShot(8000, this, [this]() {
+        if (m_socket->state() != QAbstractSocket::ConnectedState
+            && m_socket->state() != QAbstractSocket::UnconnectedState)
+        {
+            LOG_WARN(QStringLiteral("connect timeout"));
+            m_socket->abort();
+            failAll(QStringLiteral("连接服务器超时"));
+            emit connectFailed(QStringLiteral("连接服务器超时"));
+        }
+    });
+}
+
+void ImClient::disconnectFromServer()
+{
+    m_intentionalClose = true;
+    m_heartbeat->stop();
+    m_codec->reset();
+    if (m_socket->state() != QAbstractSocket::UnconnectedState)
+        m_socket->abort();
+    failAll(QStringLiteral("已断开连接"));
+}
+
+void ImClient::clearProfile()
+{
+    m_uid = 0;
+    m_account.clear();
+    m_nickname.clear();
+    m_phone.clear();
+    m_signature.clear();
+    m_avatarVer = 0;
+    m_sessionPassword.clear();
+}
+
+void ImClient::login(const QString &account, const QString &password, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("account"), account);
+    body.insert(QStringLiteral("password"), password);
+    request(Proto::Type::Account, Proto::Cmd::Login, body, [this, cb, account, password](const QJsonObject &reply) {
+        if (ok(reply))
+        {
+            applyLoginProfile(reply);
+            m_sessionPassword = password;
+            LOG_INFO(QStringLiteral("login ok uid=%1 account=%2")
+                         .arg(m_uid)
+                         .arg(m_account));
+        }
+        else
+        {
+            LOG_WARN(QStringLiteral("login failed account=%1 %2")
+                         .arg(account)
+                         .arg(messageOf(reply)));
+        }
+        if (cb)
+            cb(reply);
+    });
+}
+
+void ImClient::logout(Reply cb)
+{
+    request(Proto::Type::Account, Proto::Cmd::Logout, QJsonObject(), [this, cb](const QJsonObject &reply) {
+        clearProfile();
+        if (cb)
+            cb(reply);
+    });
+}
+
+void ImClient::registerUser(const QString &nickname, const QString &account, const QString &phone,
+                            const QString &smsCode, const QString &password, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("nickname"), nickname);
+    body.insert(QStringLiteral("account"), account);
+    body.insert(QStringLiteral("phone"), phone);
+    body.insert(QStringLiteral("sms_code"), smsCode);
+    body.insert(QStringLiteral("password"), password);
+    request(Proto::Type::Account, Proto::Cmd::Register, body, cb);
+}
+
+void ImClient::sendCode(const QString &phone, const QString &scene, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("phone"), phone);
+    body.insert(QStringLiteral("scene"), scene);
+    request(Proto::Type::Account, Proto::Cmd::SendCode, body, cb);
+}
+
+void ImClient::resetPassword(qint64 uid, const QString &phone, const QString &smsCode,
+                             const QString &password, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("uid"), uid);
+    body.insert(QStringLiteral("phone"), phone);
+    body.insert(QStringLiteral("sms_code"), smsCode);
+    body.insert(QStringLiteral("password"), password);
+    request(Proto::Type::Account, Proto::Cmd::ResetPassword, body, cb);
+}
+
+void ImClient::changePassword(const QString &oldPassword, const QString &password, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("old_password"), oldPassword);
+    body.insert(QStringLiteral("password"), password);
+    request(Proto::Type::Account, Proto::Cmd::ChangePassword, body,
+            [this, cb, password](const QJsonObject &reply) {
+                if (ok(reply) && !password.isEmpty())
+                    m_sessionPassword = password;
+                if (cb)
+                    cb(reply);
+            });
+}
+
+void ImClient::friendList(Reply cb)
+{
+    request(Proto::Type::Friend, Proto::Cmd::FriendList, QJsonObject(), cb);
+}
+
+void ImClient::searchUser(const QString &keyword, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("keyword"), keyword);
+    request(Proto::Type::Friend, Proto::Cmd::SearchUser, body, cb);
+}
+
+void ImClient::addFriend(qint64 uid, Reply cb)
+{
+    addFriend(uid, QString(), cb);
+}
+
+void ImClient::addFriend(qint64 uid, const QString &message, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("uid"), uid);
+    if (!message.isEmpty())
+        body.insert(QStringLiteral("message"), message);
+    request(Proto::Type::Friend, Proto::Cmd::AddFriend, body, cb);
+}
+
+void ImClient::addFriendAccount(const QString &account, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("account"), account);
+    request(Proto::Type::Friend, Proto::Cmd::AddFriend, body, cb);
+}
+
+void ImClient::friendRequestList(Reply cb)
+{
+    request(Proto::Type::Friend, Proto::Cmd::FriendRequestList, QJsonObject(), cb);
+}
+
+void ImClient::handleFriendRequest(qint64 id, int action, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("id"), id);
+    body.insert(QStringLiteral("action"), action);
+    request(Proto::Type::Friend, Proto::Cmd::HandleFriendRequest, body, cb);
+}
+
+void ImClient::getPrivacy(Reply cb)
+{
+    request(Proto::Type::Account, Proto::Cmd::GetPrivacy, QJsonObject(), cb);
+}
+
+void ImClient::setPrivacy(int allowSearchAccount, int allowSearchPhone, int allowAddFriend, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("allow_search_account"), allowSearchAccount);
+    body.insert(QStringLiteral("allow_search_phone"), allowSearchPhone);
+    body.insert(QStringLiteral("allow_add_friend"), allowAddFriend);
+    request(Proto::Type::Account, Proto::Cmd::SetPrivacy, body, cb);
+}
+
+void ImClient::stickerPackList(Reply cb)
+{
+    request(Proto::Type::Sticker, Proto::Cmd::StickerPackList, QJsonObject(), cb);
+}
+
+void ImClient::stickerPack(qint64 packId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("pack_id"), packId);
+    request(Proto::Type::Sticker, Proto::Cmd::StickerPack, body, cb);
+}
+
+void ImClient::getSticker(qint64 packId, const QString &code, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("pack_id"), packId);
+    body.insert(QStringLiteral("code"), code);
+    request(Proto::Type::Sticker, Proto::Cmd::GetSticker, body, cb);
+}
+
+void ImClient::convList(Reply cb)
+{
+    request(Proto::Type::Message, Proto::Cmd::ConvList, QJsonObject(), cb);
+}
+
+void ImClient::msgHistory(qint64 peerUid, qint64 lastId, int limit, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("peer_uid"), peerUid);
+    body.insert(QStringLiteral("last_id"), lastId);
+    body.insert(QStringLiteral("limit"), limit);
+    request(Proto::Type::Message, Proto::Cmd::MsgHistory, body, cb);
+}
+
+void ImClient::deleteConv(qint64 peerUid, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("peer_uid"), peerUid);
+    request(Proto::Type::Message, Proto::Cmd::DeleteConv, body, cb);
+}
+
+void ImClient::setUnread(qint64 peerUid, int unread, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("peer_uid"), peerUid);
+    body.insert(QStringLiteral("unread"), unread);
+    request(Proto::Type::Message, Proto::Cmd::SetUnread, body, cb);
+}
+
+void ImClient::deleteFriend(qint64 uid, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("uid"), uid);
+    request(Proto::Type::Friend, Proto::Cmd::DeleteFriend, body, cb);
+}
+
+void ImClient::setRemark(qint64 uid, const QString &remark, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("uid"), uid);
+    body.insert(QStringLiteral("remark"), remark);
+    if (remark.isEmpty())
+        body.insert(QStringLiteral("clear"), 1);
+    request(Proto::Type::Friend, Proto::Cmd::SetRemark, body, cb);
+}
+
+void ImClient::getProfile(qint64 uid, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("uid"), uid);
+    request(Proto::Type::Friend, Proto::Cmd::GetProfile, body, cb);
+}
+
+void ImClient::setProfile(const QString &nickname, const QString &signature, const QByteArray &avatarJpeg,
+                          Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("nickname"), nickname);
+    body.insert(QStringLiteral("signature"), signature);
+    if (!avatarJpeg.isEmpty())
+        body.insert(QStringLiteral("avatar"), QString::fromLatin1(avatarJpeg.toBase64()));
+    request(Proto::Type::Account, Proto::Cmd::SetProfile, body, cb);
+}
+
+void ImClient::getAvatar(qint64 uid, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("uid"), uid);
+    request(Proto::Type::Account, Proto::Cmd::GetAvatar, body, cb);
+}
+
+void ImClient::putFile(const QString &name, const QString &mime, qint64 size, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("name"), name);
+    body.insert(QStringLiteral("mime"), mime);
+    body.insert(QStringLiteral("size"), size);
+    request(Proto::Type::Message, Proto::Cmd::PutFile, body, cb);
+}
+
+void ImClient::getFile(qint64 fileId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("file_id"), fileId);
+    request(Proto::Type::Message, Proto::Cmd::GetFile, body, cb, 60000);
+}
+
+void ImClient::chat(qint64 toUid, const QString &content, Reply cb)
+{
+    chat(toUid, im::MsgType::Text, content, cb);
+}
+
+void ImClient::chat(qint64 toUid, int msgType, const QString &content, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("to_uid"), toUid);
+    body.insert(QStringLiteral("msg_type"), msgType);
+    body.insert(QStringLiteral("content"), content);
+    request(Proto::Type::Message, Proto::Cmd::Chat, body, cb);
+}
+
+bool ImClient::ok(const QJsonObject &reply)
+{
+    return reply.value(QStringLiteral("code")).toInt() == Proto::Err::Ok;
+}
+
+QString ImClient::messageOf(const QJsonObject &reply)
+{
+    return Proto::errorText(reply.value(QStringLiteral("code")).toInt(),
+                            reply.value(QStringLiteral("msg")).toString());
+}
+
+void ImClient::request(Proto::Type type, Proto::Cmd cmd, const QJsonObject &body, Reply cb, int timeoutMs)
+{
+    // Login/Register 等不必等 SetLang。只有随后要拼 CDN 拉资源的命令需要等 limits/cdn。
+    const bool waitConfig = isConnected() && !m_configReady
+        && (cmd == Proto::Cmd::GetAvatar || cmd == Proto::Cmd::GetFile
+            || cmd == Proto::Cmd::GetSticker);
+    if (!isConnected() || waitConfig)
+    {
+        Queued q;
+        q.type = static_cast<quint16>(type);
+        q.cmd = static_cast<quint16>(cmd);
+        q.body = body;
+        q.cb = cb;
+        q.timeoutMs = timeoutMs;
+        m_queue.push_back(q);
+        if (!isConnected())
+            connectToServer();
+        else
+            LOG_DEBUG(QStringLiteral("queue %1/%2 waiting SetLang")
+                          .arg(QLatin1String(Proto::typeName(static_cast<quint16>(type))))
+                          .arg(QLatin1String(Proto::cmdName(static_cast<quint16>(cmd)))));
+        return;
+    }
+
+    const quint32 seq = ++m_seq;
+    if (cb)
+        m_pending.insert(seq, Pending{cb});
+    sendRaw(type, cmd, seq, toBody(body));
+
+    QTimer::singleShot(qMax(1000, timeoutMs), this, [this, seq]() {
+        auto it = m_pending.find(seq);
+        if (it == m_pending.end())
+            return;
+        const Reply cb = it->cb;
+        m_pending.erase(it);
+        LOG_WARN(QStringLiteral("request timeout seq=%1").arg(seq));
+        if (cb)
+            cb(netError(QStringLiteral("请求超时")));
+    });
+}
+
+void ImClient::flushQueue()
+{
+    const QVector<Queued> queued = m_queue;
+    m_queue.clear();
+    for (const Queued &q : queued)
+        request(static_cast<Proto::Type>(q.type), static_cast<Proto::Cmd>(q.cmd), q.body, q.cb, q.timeoutMs);
+}
+
+void ImClient::failAll(const QString &reason)
+{
+    const QJsonObject err = netError(reason);
+    const auto pending = m_pending;
+    m_pending.clear();
+    for (auto it = pending.begin(); it != pending.end(); ++it)
+    {
+        if (it->cb)
+            it->cb(err);
+    }
+    const QVector<Queued> queued = m_queue;
+    m_queue.clear();
+    for (const Queued &q : queued)
+    {
+        if (q.cb)
+            q.cb(err);
+    }
+}
+
+void ImClient::sendRaw(Proto::Type type, Proto::Cmd cmd, quint32 seq, const QByteArray &body)
+{
+    if (!isConnected())
+        return;
+    LOG_DEBUG(packetDump(">> send", static_cast<quint16>(type), static_cast<quint16>(cmd), seq, parseBody(body)));
+    m_socket->write(m_codec->encode(static_cast<quint16>(type), static_cast<quint16>(cmd), seq, body));
+    m_socket->flush();
+}
+
+void ImClient::onReadyRead()
+{
+    QVector<Proto::Packet> packets;
+    const Proto::DecodeStatus st = m_codec->feed(m_socket->readAll(), &packets);
+    if (st == Proto::DecodeStatus::BadMagic || st == Proto::DecodeStatus::TooLarge)
+    {
+        LOG_ERROR(st == Proto::DecodeStatus::BadMagic
+                      ? QStringLiteral("bad packet: magic mismatch")
+                      : QStringLiteral("bad packet: body too large"));
+        failAll(QStringLiteral("协议错误"));
+        m_socket->abort();
+        return;
+    }
+    for (const Proto::Packet &pkt : packets)
+        handlePacket(pkt);
+}
+
+void ImClient::onConnected()
+{
+    m_codec->reset();
+    m_configReady = false;
+    {
+        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) {
+            if (ok(reply))
+                applyLimitsFromReply(reply);
+            else
+                LOG_WARN(QStringLiteral("SetLang failed: %1").arg(messageOf(reply)));
+            m_configReady = true;
+            flushQueue();
+        });
+    }
+    // 连接过程中点的登录/注册立刻发出,不要卡在 SetLang 回包上
+    flushQueue();
+    m_heartbeat->start();
+    LOG_INFO(QStringLiteral("connected %1:%2")
+                 .arg(m_socket->peerName())
+                 .arg(m_socket->peerPort()));
+    emit connected();
+}
+
+void ImClient::onDisconnected()
+{
+    m_heartbeat->stop();
+    m_codec->reset();
+    m_configReady = false;
+    if (m_intentionalClose)
+        LOG_INFO(QStringLiteral("disconnected"));
+    else
+        LOG_WARN(QStringLiteral("connection lost"));
+    if (!m_intentionalClose)
+        failAll(QStringLiteral("连接已断开"));
+    emit disconnected();
+}
+
+void ImClient::onSocketError()
+{
+    if (m_socket->state() == QAbstractSocket::ConnectedState)
+        return;
+    const QString reason = m_socket->errorString().isEmpty()
+        ? QStringLiteral("无法连接服务器")
+        : m_socket->errorString();
+    LOG_ERROR(QStringLiteral("socket error: %1").arg(reason));
+    failAll(reason);
+    emit connectFailed(reason);
+}
+
+void ImClient::onHeartbeat()
+{
+    if (!isConnected())
+        return;
+    const quint32 seq = ++m_seq;
+    sendRaw(Proto::Type::System, Proto::Cmd::Heartbeat, seq, QByteArray());
+}
+
+void ImClient::handlePacket(const Proto::Packet &pkt)
+{
+    const QJsonObject obj = parseBody(pkt.body);
+    LOG_DEBUG(packetDump("<< recv", pkt.type, pkt.cmd, pkt.seq, obj));
+
+    if (pkt.cmd == static_cast<quint16>(Proto::Cmd::Kicked))
+    {
+        LOG_WARN(QStringLiteral("kicked by server"));
+        clearProfile();
+        emit kicked();
+        return;
+    }
+    if (pkt.seq == 0 && pkt.cmd == static_cast<quint16>(Proto::Cmd::ChatNotify))
+    {
+        emit chatNotify(obj);
+        return;
+    }
+    if (pkt.seq == 0 && pkt.cmd == static_cast<quint16>(Proto::Cmd::FriendRequestNotify))
+    {
+        emit friendRequestNotify(obj);
+        return;
+    }
+    if (pkt.seq == 0 && pkt.cmd == static_cast<quint16>(Proto::Cmd::FriendAcceptedNotify))
+    {
+        emit friendAcceptedNotify(obj);
+        return;
+    }
+    if (pkt.seq == 0)
+        return;
+
+    auto it = m_pending.find(pkt.seq);
+    if (it == m_pending.end())
+        return;
+    const Reply cb = it->cb;
+    m_pending.erase(it);
+    if (cb)
+        cb(obj.isEmpty() ? netError(QStringLiteral("空响应")) : obj);
+}
+
+void ImClient::applyLoginProfile(const QJsonObject &reply)
+{
+    m_uid = Proto::jsonI64(reply, QStringLiteral("uid"));
+    m_account = reply.value(QStringLiteral("account")).toString();
+    m_nickname = reply.value(QStringLiteral("nickname")).toString();
+    m_phone = reply.value(QStringLiteral("phone")).toString();
+    m_signature = reply.value(QStringLiteral("signature")).toString();
+    m_avatarVer = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("avatar_ver")));
+    if (m_nickname.isEmpty())
+        m_nickname = m_account;
+}
+
+void ImClient::applyLocalProfile(const QString &nickname, const QString &signature, int avatarVer)
+{
+    if (!nickname.isEmpty())
+        m_nickname = nickname;
+    m_signature = signature;
+    if (avatarVer >= 0)
+        m_avatarVer = avatarVer;
+}
+
+void ImClient::applyLimitsFromReply(const QJsonObject &reply)
+{
+    const QJsonObject limits = reply.value(QStringLiteral("limits")).toObject();
+    if (!limits.isEmpty())
+        AppConfig::instance().applyServerLimits(limits);
+    const QJsonObject cdn = reply.value(QStringLiteral("cdn")).toObject();
+    if (!cdn.isEmpty())
+        AppConfig::instance().applyServerCdn(cdn);
+    const QJsonObject update = reply.value(QStringLiteral("update")).toObject();
+    if (!update.isEmpty())
+        AppConfig::instance().applyServerUpdate(update);
+    if (!limits.isEmpty() || !cdn.isEmpty() || !update.isEmpty())
+        emit limitsUpdated();
+}
+
+QJsonObject ImClient::netError(const QString &msg) const
+{
+    QJsonObject o;
+    o.insert(QStringLiteral("code"), Proto::Err::NetError);
+    o.insert(QStringLiteral("msg"), msg);
+    return o;
+}

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

@@ -0,0 +1,137 @@
+#pragma once
+
+#include "Protocol.h"
+
+#include <QByteArray>
+#include <QHash>
+#include <QJsonObject>
+#include <QObject>
+#include <QVector>
+#include <functional>
+
+class QTcpSocket;
+class QTimer;
+
+class ImClient : public QObject
+{
+    Q_OBJECT
+
+public:
+    using Reply = std::function<void(const QJsonObject &)>;
+
+    static ImClient &instance();
+
+    bool isConnected() const;
+    qint64 uid() const { return m_uid; }
+    QString account() const { return m_account; }
+    QString nickname() const { return m_nickname; }
+    QString phone() const { return m_phone; }
+    QString signature() const { return m_signature; }
+    int avatarVer() const { return m_avatarVer; }
+    QString sessionPassword() const { return m_sessionPassword; }
+    bool isIntentionalClose() const { return m_intentionalClose; }
+
+    void connectToServer();
+    void disconnectFromServer();
+    void clearProfile();
+
+    void login(const QString &account, const QString &password, Reply cb);
+    void logout(Reply cb);
+    void registerUser(const QString &nickname, const QString &account, const QString &phone,
+                      const QString &smsCode, const QString &password, Reply cb);
+    void sendCode(const QString &phone, const QString &scene, Reply cb);
+    void resetPassword(qint64 uid, const QString &phone, const QString &smsCode,
+                       const QString &password, Reply cb);
+    void changePassword(const QString &oldPassword, const QString &password, Reply cb);
+    void friendList(Reply cb);
+    void searchUser(const QString &keyword, Reply cb);
+    void addFriend(qint64 uid, Reply cb);
+    void addFriend(qint64 uid, const QString &message, Reply cb);
+    void addFriendAccount(const QString &account, Reply cb);
+    void friendRequestList(Reply cb);
+    void handleFriendRequest(qint64 id, int action, Reply cb);
+    void getPrivacy(Reply cb);
+    void setPrivacy(int allowSearchAccount, int allowSearchPhone, int allowAddFriend, Reply cb);
+    void stickerPackList(Reply cb);
+    void stickerPack(qint64 packId, Reply cb);
+    void getSticker(qint64 packId, const QString &code, Reply cb);
+    void convList(Reply cb);
+    void msgHistory(qint64 peerUid, qint64 lastId, int limit, Reply cb);
+    void deleteConv(qint64 peerUid, 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);
+    void getProfile(qint64 uid, Reply cb);
+    void setProfile(const QString &nickname, const QString &signature, const QByteArray &avatarJpeg,
+                    Reply cb);
+    void getAvatar(qint64 uid, Reply cb);
+    void putFile(const QString &name, const QString &mime, qint64 size, Reply cb);
+    void getFile(qint64 fileId, 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);
+
+    static bool ok(const QJsonObject &reply);
+    static QString messageOf(const QJsonObject &reply);
+
+signals:
+    void connected();
+    void disconnected();
+    void connectFailed(const QString &reason);
+    void kicked();
+    void limitsUpdated();
+    void chatNotify(const QJsonObject &msg);
+    void friendRequestNotify(const QJsonObject &msg);
+    void friendAcceptedNotify(const QJsonObject &msg);
+
+private:
+    explicit ImClient(QObject *parent = nullptr);
+    ~ImClient() override;
+
+    struct Pending
+    {
+        Reply cb;
+    };
+
+    void request(Proto::Type type, Proto::Cmd cmd, const QJsonObject &body, Reply cb,
+                 int timeoutMs = 15000);
+    void flushQueue();
+    void failAll(const QString &reason);
+    void sendRaw(Proto::Type type, Proto::Cmd cmd, quint32 seq, const QByteArray &body);
+    void onReadyRead();
+    void onConnected();
+    void onDisconnected();
+    void onSocketError();
+    void onHeartbeat();
+    void handlePacket(const Proto::Packet &pkt);
+    void applyLoginProfile(const QJsonObject &reply);
+    void applyLimitsFromReply(const QJsonObject &reply);
+    QJsonObject netError(const QString &msg) const;
+
+    QTcpSocket *m_socket = nullptr;
+    QTimer *m_heartbeat = nullptr;
+    Proto::PacketCodec *m_codec = nullptr;
+    quint32 m_seq = 0;
+    bool m_intentionalClose = false;
+    bool m_configReady = false;
+
+    qint64 m_uid = 0;
+    QString m_account;
+    QString m_nickname;
+    QString m_phone;
+    QString m_signature;
+    int m_avatarVer = 0;
+    QString m_sessionPassword;
+
+    QHash<quint32, Pending> m_pending;
+
+    struct Queued
+    {
+        quint16 type = 0;
+        quint16 cmd = 0;
+        QJsonObject body;
+        Reply cb;
+        int timeoutMs = 15000;
+    };
+    QVector<Queued> m_queue;
+};

+ 114 - 0
Client/src/network/Protocol.cpp

@@ -0,0 +1,114 @@
+#include "Protocol.h"
+
+#include "common/i18n.h"
+
+#include <QJsonObject>
+#include <QJsonValue>
+#include <cstring>
+
+namespace Proto
+{
+    quint16 readU16(const char *p)
+    {
+        return static_cast<quint16>(
+            (static_cast<quint8>(p[0]) << 8) | static_cast<quint8>(p[1]));
+    }
+
+    quint32 readU32(const char *p)
+    {
+        return (static_cast<quint32>(static_cast<quint8>(p[0])) << 24)
+             | (static_cast<quint32>(static_cast<quint8>(p[1])) << 16)
+             | (static_cast<quint32>(static_cast<quint8>(p[2])) << 8)
+             | (static_cast<quint32>(static_cast<quint8>(p[3])));
+    }
+
+    void writeU16(char *p, quint16 v)
+    {
+        p[0] = static_cast<char>((v >> 8) & 0xFF);
+        p[1] = static_cast<char>(v & 0xFF);
+    }
+
+    void writeU32(char *p, quint32 v)
+    {
+        p[0] = static_cast<char>((v >> 24) & 0xFF);
+        p[1] = static_cast<char>((v >> 16) & 0xFF);
+        p[2] = static_cast<char>((v >> 8) & 0xFF);
+        p[3] = static_cast<char>(v & 0xFF);
+    }
+
+    PacketCodec::PacketCodec(quint16 magic, quint32 maxPacket)
+        : m_magic(magic)
+        , m_maxPacket(maxPacket)
+    {
+    }
+
+    QByteArray PacketCodec::encode(quint16 type, quint16 cmd, quint32 seq, const QByteArray &body) const
+    {
+        QByteArray out;
+        out.resize(static_cast<int>(HeaderSize) + body.size());
+        char *p = out.data();
+        writeU16(p, m_magic);
+        p[2] = static_cast<char>(type & 0xFF);
+        p[3] = static_cast<char>(cmd & 0xFF);
+        writeU32(p + 4, seq);
+        writeU32(p + 8, static_cast<quint32>(body.size()));
+        if (!body.isEmpty())
+            memcpy(p + HeaderSize, body.constData(), static_cast<size_t>(body.size()));
+        return out;
+    }
+
+    DecodeStatus PacketCodec::feed(const QByteArray &chunk, QVector<Packet> *out)
+    {
+        if (!chunk.isEmpty())
+            m_buf.append(chunk);
+
+        while (m_buf.size() >= static_cast<int>(HeaderSize))
+        {
+            const char *p = m_buf.constData();
+            const quint16 magic = readU16(p);
+            if (magic != m_magic)
+                return DecodeStatus::BadMagic;
+            const quint32 bodyLen = readU32(p + 8);
+            if (bodyLen > m_maxPacket)
+                return DecodeStatus::TooLarge;
+            const int total = static_cast<int>(HeaderSize + bodyLen);
+            if (m_buf.size() < total)
+                return DecodeStatus::NeedMore;
+
+            Packet pkt;
+            pkt.magic = magic;
+            pkt.type = static_cast<quint8>(p[2]);
+            pkt.cmd = static_cast<quint8>(p[3]);
+            pkt.seq = readU32(p + 4);
+            if (bodyLen > 0)
+                pkt.body = m_buf.mid(static_cast<int>(HeaderSize), static_cast<int>(bodyLen));
+            out->push_back(pkt);
+            m_buf.remove(0, total);
+        }
+        return out->isEmpty() ? DecodeStatus::NeedMore : DecodeStatus::Ok;
+    }
+
+    void PacketCodec::reset()
+    {
+        m_buf.clear();
+    }
+
+    QString errorText(int code, const QString &serverMsg)
+    {
+        if (code != Err::Ok && !serverMsg.isEmpty())
+            return serverMsg;
+        if (code == Err::NetError)
+            return QStringLiteral("网络错误");
+        return QString::fromUtf8(im::i18n::err_text(code));
+    }
+
+    qint64 jsonI64(const QJsonObject &obj, const QString &key)
+    {
+        const QJsonValue v = obj.value(key);
+        if (v.isDouble())
+            return static_cast<qint64>(v.toDouble());
+        if (v.isString())
+            return v.toString().toLongLong();
+        return 0;
+    }
+}

+ 67 - 0
Client/src/network/Protocol.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include "common/protocol_def.h"
+
+#include <QByteArray>
+#include <QJsonObject>
+#include <QString>
+#include <QVector>
+
+namespace Proto
+{
+    using im::Type;
+    using im::Cmd;
+
+    constexpr quint32 HeaderSize = im::kHeaderSize;
+
+    namespace Err
+    {
+        using namespace im::Err;
+        constexpr int NetError = -1;
+    }
+
+    inline const char *typeName(quint16 type)
+    {
+        return im::type_name(type);
+    }
+
+    inline const char *cmdName(quint16 cmd)
+    {
+        return im::cmd_name(cmd);
+    }
+
+    struct Packet
+    {
+        quint16 magic = 0;
+        quint16 type = 0;
+        quint16 cmd = 0;
+        quint32 seq = 0;
+        QByteArray body;
+    };
+
+    enum class DecodeStatus
+    {
+        Ok,
+        NeedMore,
+        BadMagic,
+        TooLarge
+    };
+
+    class PacketCodec
+    {
+    public:
+        PacketCodec(quint16 magic, quint32 maxPacket);
+
+        QByteArray encode(quint16 type, quint16 cmd, quint32 seq, const QByteArray &body) const;
+        DecodeStatus feed(const QByteArray &chunk, QVector<Packet> *out);
+        void reset();
+
+    private:
+        quint16 m_magic = 0;
+        quint32 m_maxPacket = 65536;
+        QByteArray m_buf;
+    };
+
+    QString errorText(int code, const QString &serverMsg = QString());
+    qint64 jsonI64(const QJsonObject &obj, const QString &key);
+}

+ 1 - 0
Client/src/session/.gitkeep

@@ -0,0 +1 @@
+

+ 278 - 0
Client/src/widgets/AvatarCropDialog.cpp

@@ -0,0 +1,278 @@
+#include "AvatarCropDialog.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+
+#include <QMouseEvent>
+#include <QPainter>
+#include <QPushButton>
+#include <QWheelEvent>
+#include <QHBoxLayout>
+#include <QVBoxLayout>
+#include <QBuffer>
+#include <QCursor>
+#include <QIODevice>
+#include <QLabel>
+#include <QPainterPath>
+#include <algorithm>
+
+namespace {
+
+class CropCanvas : public QWidget
+{
+public:
+    explicit CropCanvas(const QImage &image, QWidget *parent = nullptr)
+        : QWidget(parent)
+        , m_src(image)
+    {
+        setFixedSize(400, 400);
+        setMouseTracking(true);
+        setCursor(Qt::OpenHandCursor);
+        layoutImage();
+    }
+
+    QImage cropped() const
+    {
+        if (m_src.isNull() || m_imageRect.width() <= 0 || m_crop.width() <= 0)
+            return QImage();
+        const qreal sx = static_cast<qreal>(m_src.width()) / m_imageRect.width();
+        const qreal sy = static_cast<qreal>(m_src.height()) / m_imageRect.height();
+        QRect src(
+            qRound((m_crop.x() - m_imageRect.x()) * sx),
+            qRound((m_crop.y() - m_imageRect.y()) * sy),
+            qRound(m_crop.width() * sx),
+            qRound(m_crop.height() * sy));
+        src = src.intersected(m_src.rect());
+        if (src.width() < 8 || src.height() < 8)
+            return QImage();
+        return m_src.copy(src).scaled(200, 200, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
+    }
+
+protected:
+    void paintEvent(QPaintEvent *) override
+    {
+        QPainter p(this);
+        p.fillRect(rect(), QColor("#1A1A1A"));
+        if (m_src.isNull())
+            return;
+        p.setRenderHint(QPainter::SmoothPixmapTransform, true);
+        p.drawImage(m_imageRect, m_src);
+
+        QPainterPath overlay;
+        overlay.addRect(rect());
+        QPainterPath hole;
+        hole.addRect(m_crop);
+        p.setPen(Qt::NoPen);
+        p.setBrush(QColor(0, 0, 0, 140));
+        p.drawPath(overlay.subtracted(hole));
+
+        p.setBrush(Qt::NoBrush);
+        p.setPen(QPen(QColor("#FFFFFF"), 1));
+        p.drawRect(m_crop.adjusted(0, 0, -1, -1));
+
+        const int h = 8;
+        p.setBrush(QColor("#07C160"));
+        p.setPen(Qt::NoPen);
+        p.drawRect(QRect(m_crop.left(), m_crop.top(), h, h));
+        p.drawRect(QRect(m_crop.right() - h + 1, m_crop.top(), h, h));
+        p.drawRect(QRect(m_crop.left(), m_crop.bottom() - h + 1, h, h));
+        p.drawRect(QRect(m_crop.right() - h + 1, m_crop.bottom() - h + 1, h, h));
+    }
+
+    void mousePressEvent(QMouseEvent *e) override
+    {
+        if (e->button() != Qt::LeftButton)
+            return;
+        m_last = e->pos();
+        m_drag = hit(e->pos());
+        if (m_drag == Move)
+            setCursor(Qt::ClosedHandCursor);
+        else if (m_drag != None)
+            setCursor(Qt::SizeFDiagCursor);
+    }
+
+    void mouseMoveEvent(QMouseEvent *e) override
+    {
+        if (m_drag == None)
+        {
+            const Drag d = hit(e->pos());
+            if (d == Move)
+                setCursor(Qt::OpenHandCursor);
+            else if (d != None)
+                setCursor(Qt::SizeFDiagCursor);
+            else
+                setCursor(Qt::ArrowCursor);
+            return;
+        }
+        const QPoint p = e->pos();
+        if (m_drag == Move)
+        {
+            m_crop.translate(p - m_last);
+            m_last = p;
+            clampCrop();
+        }
+        else
+        {
+            resizeCrop(p);
+        }
+        update();
+    }
+
+    void mouseReleaseEvent(QMouseEvent *) override
+    {
+        m_drag = None;
+        setCursor(Qt::OpenHandCursor);
+    }
+
+    void wheelEvent(QWheelEvent *e) override
+    {
+        const int delta = e->angleDelta().y() > 0 ? 12 : -12;
+        const int s = qBound(32, m_crop.width() + delta,
+                             qMin(m_imageRect.width(), m_imageRect.height()));
+        const QPoint c = m_crop.center();
+        m_crop = QRect(0, 0, s, s);
+        m_crop.moveCenter(c);
+        clampCrop();
+        update();
+    }
+
+private:
+    enum Drag { None, Move, TL, TR, BL, BR };
+
+    void layoutImage()
+    {
+        if (m_src.isNull())
+            return;
+        QRect box = rect().adjusted(8, 8, -8, -8);
+        const QSize fitted = m_src.size().scaled(box.size(), Qt::KeepAspectRatio);
+        m_imageRect = QRect(QPoint(0, 0), fitted);
+        m_imageRect.moveCenter(box.center());
+        const int s = qMax(32, qMin(m_imageRect.width(), m_imageRect.height()) * 4 / 5);
+        m_crop = QRect(0, 0, s, s);
+        m_crop.moveCenter(m_imageRect.center());
+        clampCrop();
+    }
+
+    Drag hit(const QPoint &p) const
+    {
+        const int z = 12;
+        auto near = [&](int x, int y) {
+            return QRect(x - z / 2, y - z / 2, z, z).contains(p);
+        };
+        if (near(m_crop.left(), m_crop.top()))
+            return TL;
+        if (near(m_crop.right(), m_crop.top()))
+            return TR;
+        if (near(m_crop.left(), m_crop.bottom()))
+            return BL;
+        if (near(m_crop.right(), m_crop.bottom()))
+            return BR;
+        if (m_crop.contains(p))
+            return Move;
+        return None;
+    }
+
+    void clampCrop()
+    {
+        int s = m_crop.width();
+        s = qBound(32, s, qMin(m_imageRect.width(), m_imageRect.height()));
+        m_crop.setSize(QSize(s, s));
+        if (m_crop.left() < m_imageRect.left())
+            m_crop.moveLeft(m_imageRect.left());
+        if (m_crop.top() < m_imageRect.top())
+            m_crop.moveTop(m_imageRect.top());
+        if (m_crop.right() > m_imageRect.right())
+            m_crop.moveRight(m_imageRect.right());
+        if (m_crop.bottom() > m_imageRect.bottom())
+            m_crop.moveBottom(m_imageRect.bottom());
+    }
+
+    void resizeCrop(const QPoint &p)
+    {
+        QPoint fixed;
+        if (m_drag == TL)
+            fixed = m_crop.bottomRight();
+        else if (m_drag == TR)
+            fixed = m_crop.bottomLeft();
+        else if (m_drag == BL)
+            fixed = m_crop.topRight();
+        else
+            fixed = m_crop.topLeft();
+        const int s = qMax(32, qMax(qAbs(p.x() - fixed.x()), qAbs(p.y() - fixed.y())));
+        QRect r(0, 0, s, s);
+        if (m_drag == TL)
+            r.moveBottomRight(fixed);
+        else if (m_drag == TR)
+            r.moveBottomLeft(fixed);
+        else if (m_drag == BL)
+            r.moveTopRight(fixed);
+        else
+            r.moveTopLeft(fixed);
+        m_crop = r;
+        clampCrop();
+    }
+
+    QImage m_src;
+    QRect m_imageRect;
+    QRect m_crop;
+    Drag m_drag = None;
+    QPoint m_last;
+};
+
+}  // namespace
+
+AvatarCropDialog::AvatarCropDialog(const QImage &image, QWidget *parent)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("编辑头像"));
+    setFixedSize(440, 520);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(QStringLiteral("编辑头像"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *body = new QWidget(this);
+    auto *lay = new QVBoxLayout(body);
+    lay->setContentsMargins(20, 12, 20, 16);
+    lay->setSpacing(12);
+
+    auto *canvas = new CropCanvas(image, body);
+    lay->addWidget(canvas, 0, Qt::AlignHCenter);
+
+    auto *hint = new QLabel(QStringLiteral("拖动选择框移动,拖角或滚轮调整大小"), body);
+    hint->setAlignment(Qt::AlignCenter);
+    hint->setStyleSheet(QStringLiteral("color:#9A9A9A; font-size:12px; background:transparent;"));
+    lay->addWidget(hint);
+
+    auto *btns = new QHBoxLayout();
+    auto *cancel = UiHelpers::ghostButton(QStringLiteral("取消"), body);
+    cancel->setFixedSize(88, 32);
+    auto *ok = UiHelpers::primaryButton(QStringLiteral("确定"), body);
+    ok->setFixedSize(88, 32);
+    btns->addStretch();
+    btns->addWidget(cancel);
+    btns->addWidget(ok);
+    lay->addLayout(btns);
+    root->addWidget(body, 1);
+
+    connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
+    connect(ok, &QPushButton::clicked, this, [this, canvas]() {
+        const QImage img = canvas->cropped();
+        if (img.isNull())
+            return;
+        QBuffer buf(&m_jpeg);
+        buf.open(QIODevice::WriteOnly);
+        img.save(&buf, "JPEG", 82);
+        if (m_jpeg.isEmpty())
+            return;
+        accept();
+    });
+}

+ 15 - 0
Client/src/widgets/AvatarCropDialog.h

@@ -0,0 +1,15 @@
+#pragma once
+
+#include <QByteArray>
+#include <QDialog>
+#include <QImage>
+
+class AvatarCropDialog : public QDialog
+{
+public:
+    explicit AvatarCropDialog(const QImage &image, QWidget *parent = nullptr);
+    QByteArray jpegBytes() const { return m_jpeg; }
+
+private:
+    QByteArray m_jpeg;
+};

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

@@ -0,0 +1,149 @@
+#include "ConfirmDialog.h"
+#include "FramelessHelper.h"
+#include "UiHelpers.h"
+
+#include <QFrame>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QMouseEvent>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+ConfirmDialog::ConfirmDialog(QWidget *parent, const QString &title, const QString &text,
+                             const QString &okText, const QString &cancelText,
+                             bool dangerOk, bool choiceMode)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(title);
+    setFixedWidth(300);
+    UiHelpers::setupFramelessDialog(this);
+    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, 26, 28, 22);
+    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);
+
+    auto *line = new QFrame(this);
+    line->setFixedHeight(1);
+    line->setStyleSheet(QStringLiteral("background:#E5E5E5; border:none;"));
+
+    auto *btns = new QWidget(this);
+    btns->setFixedHeight(48);
+    auto *btnLay = new QHBoxLayout(btns);
+    btnLay->setContentsMargins(0, 0, 0, 0);
+    btnLay->setSpacing(0);
+
+    const QString btnQss = QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:none; font-size:15px; border-radius:0; }"
+        "QPushButton:hover { background:#F7F7F7; }"
+        "QPushButton:pressed { background:#EFEFEF; }");
+
+    auto *cancel = new QPushButton(cancelText, btns);
+    cancel->setCursor(Qt::PointingHandCursor);
+    cancel->setFixedHeight(48);
+    cancel->setAutoDefault(false);
+    cancel->setDefault(false);
+    cancel->setStyleSheet(btnQss + QStringLiteral("QPushButton { color:#191919; }"));
+
+    auto *split = new QFrame(btns);
+    split->setFixedWidth(1);
+    split->setStyleSheet(QStringLiteral("background:#E5E5E5; border:none;"));
+
+    auto *ok = new QPushButton(okText, btns);
+    ok->setCursor(Qt::PointingHandCursor);
+    ok->setFixedHeight(48);
+    ok->setAutoDefault(false);
+    ok->setDefault(false);
+    ok->setStyleSheet(btnQss + QStringLiteral("QPushButton { color:%1; }")
+                          .arg(dangerOk ? QStringLiteral("#FA5151") : QStringLiteral("#07C160")));
+
+    btnLay->addWidget(cancel, 1);
+    btnLay->addWidget(split);
+    btnLay->addWidget(ok, 1);
+
+    root->addWidget(body);
+    root->addWidget(line);
+    root->addWidget(btns);
+
+    if (choiceMode)
+    {
+        connect(cancel, &QPushButton::clicked, this, [this]() { done(1); });
+        connect(ok, &QPushButton::clicked, this, [this]() { done(2); });
+    }
+    else
+    {
+        connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
+        connect(ok, &QPushButton::clicked, this, &QDialog::accept);
+    }
+
+    adjustSize();
+    setFixedHeight(height());
+}
+
+bool ConfirmDialog::ask(QWidget *parent, const QString &title, const QString &text,
+                        const QString &okText, const QString &cancelText)
+{
+    ConfirmDialog dlg(parent, title, text, okText, cancelText, true);
+    if (parent)
+    {
+        const QPoint c = parent->mapToGlobal(parent->rect().center());
+        dlg.move(c.x() - dlg.width() / 2, c.y() - dlg.height() / 2);
+    }
+    return dlg.exec() == QDialog::Accepted;
+}
+
+int ConfirmDialog::chooseAction(QWidget *parent, const QString &title, const QString &text,
+                                const QString &leftText, const QString &rightText)
+{
+    ConfirmDialog dlg(parent, title, text, rightText, leftText, false, true);
+    if (parent)
+    {
+        const QPoint c = parent->mapToGlobal(parent->rect().center());
+        dlg.move(c.x() - dlg.width() / 2, c.y() - dlg.height() / 2);
+    }
+    const int r = dlg.exec();
+    return (r == 1 || r == 2) ? r : 0;
+}
+
+void ConfirmDialog::mousePressEvent(QMouseEvent *event)
+{
+    if (event->button() == Qt::LeftButton)
+    {
+        m_dragging = true;
+        m_dragOffset = event->globalPos() - frameGeometry().topLeft();
+    }
+    QDialog::mousePressEvent(event);
+}
+
+void ConfirmDialog::mouseMoveEvent(QMouseEvent *event)
+{
+    if (m_dragging && (event->buttons() & Qt::LeftButton))
+        move(event->globalPos() - m_dragOffset);
+    QDialog::mouseMoveEvent(event);
+}
+
+void ConfirmDialog::mouseReleaseEvent(QMouseEvent *event)
+{
+    m_dragging = false;
+    QDialog::mouseReleaseEvent(event);
+}

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

@@ -0,0 +1,31 @@
+#pragma once
+
+#include <QDialog>
+#include <QPoint>
+#include <QString>
+
+class QMouseEvent;
+
+class ConfirmDialog : public QDialog
+{
+public:
+    static bool ask(QWidget *parent, const QString &title, const QString &text,
+                    const QString &okText = QStringLiteral("删除"),
+                    const QString &cancelText = QStringLiteral("取消"));
+    // 1=左侧, 2=右侧, 0=取消/关闭
+    static int chooseAction(QWidget *parent, const QString &title, const QString &text,
+                            const QString &leftText, const QString &rightText);
+
+protected:
+    void mousePressEvent(QMouseEvent *event) override;
+    void mouseMoveEvent(QMouseEvent *event) override;
+    void mouseReleaseEvent(QMouseEvent *event) override;
+
+private:
+    ConfirmDialog(QWidget *parent, const QString &title, const QString &text,
+                  const QString &okText, const QString &cancelText,
+                  bool dangerOk = true, bool choiceMode = false);
+
+    bool m_dragging = false;
+    QPoint m_dragOffset;
+};

+ 649 - 0
Client/src/widgets/FramelessHelper.cpp

@@ -0,0 +1,649 @@
+#include "FramelessHelper.h"
+#include "TitleBar.h"
+
+#include <QAbstractButton>
+#include <QApplication>
+#include <QCursor>
+#include <QEvent>
+#include <QMouseEvent>
+#include <QScreen>
+#include <QTimer>
+#include <QWindow>
+#include <QWindowStateChangeEvent>
+#include <QWidget>
+
+#ifdef Q_OS_WIN
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+#include <windows.h>
+#include <windowsx.h>
+#include <dwmapi.h>
+#pragma comment(lib, "dwmapi.lib")
+#endif
+
+FramelessHelper::FramelessHelper(QWidget *host, TitleBar *bar, bool resizable)
+    : QObject(host)
+    , m_host(host)
+    , m_bar(bar)
+    , m_resizable(resizable)
+{
+    if (host)
+    {
+        host->setMouseTracking(true);
+        host->setAttribute(Qt::WA_Hover, true);
+        host->installEventFilter(this);
+    }
+    if (resizable && qApp)
+    {
+        qApp->installEventFilter(this);
+        m_appFilter = true;
+    }
+}
+
+FramelessHelper::~FramelessHelper()
+{
+    clearHoverCursor();
+    if (m_filterInstalled)
+        qApp->removeNativeEventFilter(this);
+    if (m_appFilter && qApp)
+        qApp->removeEventFilter(this);
+}
+
+void FramelessHelper::cacheHwnd()
+{
+#ifdef Q_OS_WIN
+    if (!m_host)
+        return;
+    WId wid = m_host->internalWinId();
+    if (!wid)
+        wid = m_host->winId();
+    if (!wid)
+        return;
+    m_hwnd = static_cast<quintptr>(wid);
+    if (!m_filterInstalled)
+    {
+        qApp->installNativeEventFilter(this);
+        m_filterInstalled = true;
+    }
+    applyThickFrame();
+    applyShadow();
+    if (!m_resizable && !m_host->isMaximized() && !m_restoring)
+    {
+        fixupSize();
+        QTimer::singleShot(0, this, [this]() {
+            if (!m_restoring && m_host && !m_host->isMaximized())
+                fixupSize();
+        });
+    }
+#endif
+}
+
+void FramelessHelper::applyThickFrame()
+{
+#ifdef Q_OS_WIN
+    HWND hwnd = reinterpret_cast<HWND>(m_hwnd);
+    if (!hwnd || !m_host)
+        return;
+    const LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE);
+    LONG_PTR next = style | WS_CAPTION | WS_THICKFRAME;
+    if (m_resizable)
+        next |= WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
+    int w = 0;
+    int h = 0;
+    const bool fixedSize = m_host->minimumWidth() == m_host->maximumWidth()
+        && m_host->maximumWidth() < QWIDGETSIZE_MAX
+        && m_host->minimumHeight() == m_host->maximumHeight()
+        && m_host->maximumHeight() < QWIDGETSIZE_MAX;
+    const bool sized = fixedSize && nativeIntendedSize(&w, &h) && !m_host->isMaximized() && !m_restoring;
+    if (next != style)
+        SetWindowLongPtrW(hwnd, GWL_STYLE, next);
+    UINT flags = SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED;
+    if (sized)
+        SetWindowPos(hwnd, nullptr, 0, 0, w, h, flags);
+    else
+        SetWindowPos(hwnd, nullptr, 0, 0, 0, 0, flags | SWP_NOSIZE);
+#endif
+}
+
+void FramelessHelper::applyShadow()
+{
+#ifdef Q_OS_WIN
+    HWND hwnd = reinterpret_cast<HWND>(m_hwnd);
+    if (!hwnd || !m_host)
+        return;
+    const bool on = m_host->isVisible() && !m_host->isMaximized();
+    MARGINS margins = {0, 0, 0, 0};
+    if (on)
+    {
+        margins.cxLeftWidth = 1;
+        margins.cxRightWidth = 1;
+        margins.cyTopHeight = 1;
+        margins.cyBottomHeight = 1;
+    }
+    DwmExtendFrameIntoClientArea(hwnd, &margins);
+#endif
+}
+
+bool FramelessHelper::nativeIntendedSize(int *outW, int *outH) const
+{
+    if (!m_host || !outW || !outH)
+        return false;
+    int lw = m_host->width();
+    int lh = m_host->height();
+    if (m_host->minimumWidth() == m_host->maximumWidth()
+        && m_host->maximumWidth() < QWIDGETSIZE_MAX)
+        lw = m_host->maximumWidth();
+    if (m_host->minimumHeight() == m_host->maximumHeight()
+        && m_host->maximumHeight() < QWIDGETSIZE_MAX)
+        lh = m_host->maximumHeight();
+    if (lw <= 0 || lh <= 0)
+        return false;
+    const qreal dpr = qMax(qreal(1), m_host->devicePixelRatioF());
+    *outW = qRound(lw * dpr);
+    *outH = qRound(lh * dpr);
+    return true;
+}
+
+void FramelessHelper::fixupSize()
+{
+#ifdef Q_OS_WIN
+    HWND hwnd = reinterpret_cast<HWND>(m_hwnd);
+    if (!hwnd || !m_host || m_host->isMaximized() || m_restoring)
+        return;
+    int w = 0;
+    int h = 0;
+    if (!nativeIntendedSize(&w, &h))
+        return;
+    QSize logical(m_host->width(), m_host->height());
+    if (m_host->minimumWidth() == m_host->maximumWidth()
+        && m_host->maximumWidth() < QWIDGETSIZE_MAX)
+        logical.setWidth(m_host->maximumWidth());
+    if (m_host->minimumHeight() == m_host->maximumHeight()
+        && m_host->maximumHeight() < QWIDGETSIZE_MAX)
+        logical.setHeight(m_host->maximumHeight());
+    if (m_host->size() != logical)
+        m_host->resize(logical);
+    RECT rc;
+    GetWindowRect(hwnd, &rc);
+    if ((rc.right - rc.left) != w || (rc.bottom - rc.top) != h)
+    {
+        SetWindowPos(hwnd, nullptr, 0, 0, w, h,
+                     SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
+    }
+#endif
+}
+
+void FramelessHelper::rememberNormalGeometry()
+{
+    if (!m_host || m_restoring || m_wasMaximized || m_host->isMaximized() || m_host->isMinimized())
+        return;
+#ifdef Q_OS_WIN
+    HWND hwnd = reinterpret_cast<HWND>(m_hwnd);
+    if (hwnd && IsZoomed(hwnd))
+        return;
+#endif
+    const QRect g = m_host->geometry();
+    if (g.width() <= 0 || g.height() <= 0)
+        return;
+    QScreen *scr = QApplication::primaryScreen();
+    if (QWindow *win = m_host->windowHandle())
+    {
+        if (QScreen *s = win->screen())
+            scr = s;
+    }
+    if (scr)
+    {
+        const QRect avail = scr->availableGeometry();
+        if (g.width() >= avail.width() - 8 && g.height() >= avail.height() - 8)
+            return;
+    }
+    if (m_normalGeom != g)
+    {
+        m_normalGeom = g;
+        emit normalGeometryChanged(g);
+    }
+}
+
+void FramelessHelper::setNormalGeometry(const QRect &g)
+{
+    if (g.width() > 0 && g.height() > 0)
+        m_normalGeom = g;
+}
+
+void FramelessHelper::restoreNormalGeometry()
+{
+    if (!m_host || !m_normalGeom.isValid())
+        return;
+    m_host->setGeometry(m_normalGeom);
+}
+
+bool FramelessHelper::inCaptionButtons(const QPoint &pos) const
+{
+    if (!m_host)
+        return false;
+    const int barH = m_bar ? m_bar->barHeight() : 32;
+    const int band = 46 * 3;
+    return pos.y() >= 0 && pos.y() < barH && pos.x() > m_host->width() - band;
+}
+
+FramelessHelper::Region FramelessHelper::hitRegion(const QPoint &pos) const
+{
+    const int kEdge = 8;
+
+    if (!m_host || m_host->isMaximized())
+        return None;
+    if (inCaptionButtons(pos))
+        return None;
+
+    const int w = m_host->width();
+    const int h = m_host->height();
+    if (pos.x() < 0 || pos.y() < 0 || pos.x() > w || pos.y() > h)
+        return None;
+
+    const bool left = pos.x() <= kEdge;
+    const bool right = pos.x() >= w - kEdge;
+    const bool top = pos.y() <= kEdge;
+    const bool bottom = pos.y() >= h - kEdge;
+    if (left && top)
+        return TopLeft;
+    if (right && top)
+        return TopRight;
+    if (left && bottom)
+        return BottomLeft;
+    if (right && bottom)
+        return BottomRight;
+    if (left)
+        return Left;
+    if (right)
+        return Right;
+    if (top)
+        return Top;
+    if (bottom)
+        return Bottom;
+    return None;
+}
+
+void FramelessHelper::startResize(Region region)
+{
+    if (!m_host || region == None)
+        return;
+
+#ifdef Q_OS_WIN
+    WPARAM ht = HTCLIENT;
+    switch (region)
+    {
+    case Left: ht = HTLEFT; break;
+    case Right: ht = HTRIGHT; break;
+    case Top: ht = HTTOP; break;
+    case Bottom: ht = HTBOTTOM; break;
+    case TopLeft: ht = HTTOPLEFT; break;
+    case TopRight: ht = HTTOPRIGHT; break;
+    case BottomLeft: ht = HTBOTTOMLEFT; break;
+    case BottomRight: ht = HTBOTTOMRIGHT; break;
+    default: break;
+    }
+    if (ht != HTCLIENT)
+    {
+        HWND hwnd = reinterpret_cast<HWND>(m_hwnd);
+        if (!hwnd)
+            hwnd = reinterpret_cast<HWND>(m_host->internalWinId());
+        if (hwnd)
+        {
+            ReleaseCapture();
+            SendMessageW(hwnd, WM_NCLBUTTONDOWN, ht, 0);
+            return;
+        }
+    }
+#endif
+
+    m_resizing = true;
+    m_region = region;
+    m_startPos = QCursor::pos();
+    m_startGeom = m_host->geometry();
+}
+
+void FramelessHelper::applyResize(const QPoint &globalPos)
+{
+    if (!m_host || !m_resizing)
+        return;
+    const QPoint d = globalPos - m_startPos;
+    const QSize minSize = m_host->minimumSize().expandedTo(QSize(1, 1));
+    QRect g = m_startGeom;
+    switch (m_region)
+    {
+    case Left:
+        g.setLeft(qMin(m_startGeom.right() - minSize.width() + 1, m_startGeom.left() + d.x()));
+        break;
+    case Right:
+        g.setWidth(qMax(minSize.width(), m_startGeom.width() + d.x()));
+        break;
+    case Top:
+        g.setTop(qMin(m_startGeom.bottom() - minSize.height() + 1, m_startGeom.top() + d.y()));
+        break;
+    case Bottom:
+        g.setHeight(qMax(minSize.height(), m_startGeom.height() + d.y()));
+        break;
+    case TopLeft:
+        g.setLeft(qMin(m_startGeom.right() - minSize.width() + 1, m_startGeom.left() + d.x()));
+        g.setTop(qMin(m_startGeom.bottom() - minSize.height() + 1, m_startGeom.top() + d.y()));
+        break;
+    case TopRight:
+        g.setWidth(qMax(minSize.width(), m_startGeom.width() + d.x()));
+        g.setTop(qMin(m_startGeom.bottom() - minSize.height() + 1, m_startGeom.top() + d.y()));
+        break;
+    case BottomLeft:
+        g.setLeft(qMin(m_startGeom.right() - minSize.width() + 1, m_startGeom.left() + d.x()));
+        g.setHeight(qMax(minSize.height(), m_startGeom.height() + d.y()));
+        break;
+    case BottomRight:
+        g.setWidth(qMax(minSize.width(), m_startGeom.width() + d.x()));
+        g.setHeight(qMax(minSize.height(), m_startGeom.height() + d.y()));
+        break;
+    default:
+        break;
+    }
+    m_host->setGeometry(g);
+}
+
+void FramelessHelper::updateHoverCursor(const QPoint &globalPos)
+{
+    if (!m_host || !m_host->isVisible() || m_host->isMaximized())
+    {
+        clearHoverCursor();
+        return;
+    }
+    const Region r = hitRegion(m_host->mapFromGlobal(globalPos));
+    Qt::CursorShape shape = Qt::ArrowCursor;
+    switch (r)
+    {
+    case Left:
+    case Right: shape = Qt::SizeHorCursor; break;
+    case Top:
+    case Bottom: shape = Qt::SizeVerCursor; break;
+    case TopLeft:
+    case BottomRight: shape = Qt::SizeFDiagCursor; break;
+    case TopRight:
+    case BottomLeft: shape = Qt::SizeBDiagCursor; break;
+    default: break;
+    }
+
+    if (r == None)
+    {
+        clearHoverCursor();
+        return;
+    }
+    if (!m_cursorOn)
+    {
+        QApplication::setOverrideCursor(shape);
+        m_cursorOn = true;
+    }
+    else
+    {
+        QApplication::changeOverrideCursor(shape);
+    }
+}
+
+void FramelessHelper::clearHoverCursor()
+{
+    if (m_cursorOn)
+    {
+        QApplication::restoreOverrideCursor();
+        m_cursorOn = false;
+    }
+}
+
+bool FramelessHelper::eventFilter(QObject *watched, QEvent *event)
+{
+    if (watched == m_host)
+    {
+        switch (event->type())
+        {
+        case QEvent::Show:
+        case QEvent::WinIdChange:
+            cacheHwnd();
+            rememberNormalGeometry();
+            break;
+        case QEvent::Move:
+        case QEvent::Resize:
+            rememberNormalGeometry();
+            break;
+        case QEvent::WindowStateChange:
+        {
+            applyShadow();
+            auto *se = static_cast<QWindowStateChangeEvent *>(event);
+            const bool wasMax = se->oldState() & Qt::WindowMaximized;
+            if (wasMax && m_host && !m_host->isMaximized())
+            {
+                m_restoring = true;
+                QTimer::singleShot(0, this, [this]() {
+                    restoreNormalGeometry();
+                    m_restoring = false;
+                });
+            }
+            else if (!m_resizable)
+            {
+                fixupSize();
+            }
+            break;
+        }
+        default:
+            break;
+        }
+    }
+
+    if (!m_resizable || !m_host || !m_host->isVisible())
+        return QObject::eventFilter(watched, event);
+
+    if (m_resizing)
+    {
+        if (event->type() == QEvent::MouseMove)
+        {
+            applyResize(static_cast<QMouseEvent *>(event)->globalPos());
+            return true;
+        }
+        if (event->type() == QEvent::MouseButtonRelease)
+        {
+            m_resizing = false;
+            return true;
+        }
+    }
+
+    if (event->type() != QEvent::MouseButtonPress
+        && event->type() != QEvent::MouseMove
+        && event->type() != QEvent::HoverMove)
+        return QObject::eventFilter(watched, event);
+
+    auto *w = qobject_cast<QWidget *>(watched);
+    if (!w)
+        return QObject::eventFilter(watched, event);
+    if (w != m_host && !m_host->isAncestorOf(w))
+        return QObject::eventFilter(watched, event);
+
+    QPoint global;
+    if (event->type() == QEvent::HoverMove)
+        global = QCursor::pos();
+    else
+        global = static_cast<QMouseEvent *>(event)->globalPos();
+
+    if (!m_host->frameGeometry().contains(global))
+    {
+        clearHoverCursor();
+        return QObject::eventFilter(watched, event);
+    }
+
+    const QPoint local = m_host->mapFromGlobal(global);
+    const Region region = hitRegion(local);
+    updateHoverCursor(global);
+
+    if (event->type() == QEvent::MouseButtonPress
+        && static_cast<QMouseEvent *>(event)->button() == Qt::LeftButton
+        && region != None)
+    {
+        if (qobject_cast<QAbstractButton *>(w) && m_bar && m_bar->hitButtons(local))
+            return QObject::eventFilter(watched, event);
+        startResize(region);
+        return true;
+    }
+
+    return QObject::eventFilter(watched, event);
+}
+
+bool FramelessHelper::handleNativeEvent(void *message, long *result)
+{
+#ifdef Q_OS_WIN
+    if (!m_host || !result)
+        return false;
+
+    MSG *msg = static_cast<MSG *>(message);
+    if (!msg)
+        return false;
+    if (m_hwnd && reinterpret_cast<quintptr>(msg->hwnd) != m_hwnd)
+        return false;
+
+    if (msg->message == WM_SIZE)
+    {
+        if (msg->wParam == SIZE_MAXIMIZED)
+            m_wasMaximized = true;
+        else if (msg->wParam == SIZE_RESTORED && m_wasMaximized)
+        {
+            m_restoring = true;
+            m_wasMaximized = false;
+        }
+        return false;
+    }
+
+    if (msg->message == WM_WINDOWPOSCHANGING)
+    {
+        auto *wp = reinterpret_cast<WINDOWPOS *>(msg->lParam);
+        if (wp && m_host && !m_host->isMaximized() && !m_restoring && !(wp->flags & SWP_NOSIZE))
+        {
+            int w = 0;
+            int h = 0;
+            if (nativeIntendedSize(&w, &h))
+            {
+                if (m_host->minimumWidth() == m_host->maximumWidth()
+                    && m_host->maximumWidth() < QWIDGETSIZE_MAX)
+                    wp->cx = w;
+                if (m_host->minimumHeight() == m_host->maximumHeight()
+                    && m_host->maximumHeight() < QWIDGETSIZE_MAX)
+                    wp->cy = h;
+            }
+            if (m_resizable)
+            {
+                const qreal dpr = qMax(qreal(1), m_host->devicePixelRatioF());
+                QSize minSz = m_host->minimumSize();
+                if (minSz.width() > 0)
+                    wp->cx = qMax(wp->cx, qRound(minSz.width() * dpr));
+                if (minSz.height() > 0)
+                    wp->cy = qMax(wp->cy, qRound(minSz.height() * dpr));
+            }
+        }
+        return false;
+    }
+
+    if (msg->message == WM_NCCALCSIZE)
+    {
+        if (msg->wParam)
+        {
+            auto *params = reinterpret_cast<NCCALCSIZE_PARAMS *>(msg->lParam);
+            const RECT proposed = params->rgrc[0];
+            const int pw = proposed.right - proposed.left;
+            const int ph = proposed.bottom - proposed.top;
+            MONITORINFO mi;
+            mi.cbSize = sizeof(mi);
+            if (IsZoomed(msg->hwnd)
+                && GetMonitorInfoW(MonitorFromWindow(msg->hwnd, MONITOR_DEFAULTTONEAREST), &mi))
+            {
+                const int workW = mi.rcWork.right - mi.rcWork.left;
+                const int workH = mi.rcWork.bottom - mi.rcWork.top;
+                if (pw >= workW - 16 && ph >= workH - 16)
+                    params->rgrc[0] = mi.rcWork;
+            }
+        }
+        *result = 0;
+        return true;
+    }
+
+    if (m_resizable && msg->message == WM_GETMINMAXINFO)
+    {
+        auto *mmi = reinterpret_cast<MINMAXINFO *>(msg->lParam);
+        MONITORINFO mi;
+        mi.cbSize = sizeof(mi);
+        if (GetMonitorInfoW(MonitorFromWindow(msg->hwnd, MONITOR_DEFAULTTONEAREST), &mi))
+        {
+            mmi->ptMaxPosition.x = mi.rcWork.left - mi.rcMonitor.left;
+            mmi->ptMaxPosition.y = mi.rcWork.top - mi.rcMonitor.top;
+            mmi->ptMaxSize.x = mi.rcWork.right - mi.rcWork.left;
+            mmi->ptMaxSize.y = mi.rcWork.bottom - mi.rcWork.top;
+        }
+        const qreal dpr = qMax(qreal(1), m_host->devicePixelRatioF());
+        QSize minSz = m_host->minimumSize();
+        if (minSz.width() > 0)
+            mmi->ptMinTrackSize.x = qMax(1, qRound(minSz.width() * dpr));
+        if (minSz.height() > 0)
+            mmi->ptMinTrackSize.y = qMax(1, qRound(minSz.height() * dpr));
+        *result = 0;
+        return true;
+    }
+
+    if (msg->message == WM_NCACTIVATE)
+    {
+        // Keep DWM shadow, skip the default NC frame so modal disable
+        // does not flash a system border.
+        *result = DefWindowProcW(msg->hwnd, WM_NCACTIVATE, msg->wParam, -1);
+        return true;
+    }
+
+    if (msg->message == WM_NCHITTEST)
+    {
+        if (!m_resizable || m_host->isMaximized())
+        {
+            *result = HTCLIENT;
+            return true;
+        }
+
+        POINT pt = { GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam) };
+        ScreenToClient(msg->hwnd, &pt);
+        const qreal dpr = qMax(qreal(1), m_host->devicePixelRatioF());
+        const QPoint pos(qRound(static_cast<qreal>(pt.x) / dpr),
+                         qRound(static_cast<qreal>(pt.y) / dpr));
+        switch (hitRegion(pos))
+        {
+        case Left: *result = HTLEFT; return true;
+        case Right: *result = HTRIGHT; return true;
+        case Top: *result = HTTOP; return true;
+        case Bottom: *result = HTBOTTOM; return true;
+        case TopLeft: *result = HTTOPLEFT; return true;
+        case TopRight: *result = HTTOPRIGHT; return true;
+        case BottomLeft: *result = HTBOTTOMLEFT; return true;
+        case BottomRight: *result = HTBOTTOMRIGHT; return true;
+        default: break;
+        }
+        *result = HTCLIENT;
+        return true;
+    }
+#else
+    Q_UNUSED(message);
+    Q_UNUSED(result);
+#endif
+    return false;
+}
+
+bool FramelessHelper::nativeEventFilter(const QByteArray &, void *message, long *result)
+{
+#ifdef Q_OS_WIN
+    if (!m_hwnd || !m_host)
+        return false;
+    MSG *msg = static_cast<MSG *>(message);
+    if (!msg)
+        return false;
+    if (reinterpret_cast<quintptr>(msg->hwnd) != m_hwnd)
+        return false;
+    return handleNativeEvent(message, result);
+#else
+    Q_UNUSED(message);
+    Q_UNUSED(result);
+    return false;
+#endif
+}

+ 75 - 0
Client/src/widgets/FramelessHelper.h

@@ -0,0 +1,75 @@
+#pragma once
+
+#include <QAbstractNativeEventFilter>
+#include <QObject>
+#include <QPointer>
+#include <QRect>
+#include <QPoint>
+#include <QtGlobal>
+
+class QWidget;
+class TitleBar;
+class QEvent;
+
+class FramelessHelper : public QObject, public QAbstractNativeEventFilter
+{
+    Q_OBJECT
+
+public:
+    FramelessHelper(QWidget *host, TitleBar *bar, bool resizable);
+    ~FramelessHelper() override;
+
+    bool handleNativeEvent(void *message, long *result);
+    bool nativeEventFilter(const QByteArray &eventType, void *message, long *result) override;
+    void fixupSize();
+    QRect normalGeometry() const { return m_normalGeom; }
+    void setNormalGeometry(const QRect &g);
+
+signals:
+    void normalGeometryChanged(const QRect &g);
+
+protected:
+    bool eventFilter(QObject *watched, QEvent *event) override;
+
+private:
+    enum Region
+    {
+        None = 0,
+        Left,
+        Right,
+        Top,
+        Bottom,
+        TopLeft,
+        TopRight,
+        BottomLeft,
+        BottomRight
+    };
+
+    void cacheHwnd();
+    void applyThickFrame();
+    void applyShadow();
+    bool nativeIntendedSize(int *outW, int *outH) const;
+    void rememberNormalGeometry();
+    void restoreNormalGeometry();
+    bool inCaptionButtons(const QPoint &posInHost) const;
+    Region hitRegion(const QPoint &posInHost) const;
+    void startResize(Region region);
+    void applyResize(const QPoint &globalPos);
+    void updateHoverCursor(const QPoint &globalPos);
+    void clearHoverCursor();
+
+    QPointer<QWidget> m_host;
+    TitleBar *m_bar = nullptr;
+    bool m_resizable = false;
+    bool m_filterInstalled = false;
+    bool m_appFilter = false;
+    bool m_resizing = false;
+    bool m_cursorOn = false;
+    bool m_restoring = false;
+    bool m_wasMaximized = false;
+    Region m_region = None;
+    QPoint m_startPos;
+    QRect m_startGeom;
+    QRect m_normalGeom;
+    quintptr m_hwnd = 0;
+};

+ 97 - 0
Client/src/widgets/ImagePreviewDialog.cpp

@@ -0,0 +1,97 @@
+#include "ImagePreviewDialog.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+
+#include <QFile>
+#include <QFileDialog>
+#include <QFrame>
+#include <QIODevice>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QPushButton>
+#include <QScrollArea>
+#include <QVBoxLayout>
+
+ImagePreviewDialog::ImagePreviewDialog(const QPixmap &image, const QString &name,
+                                       const QByteArray &bytes, QWidget *parent, bool canForward)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(name.isEmpty() ? QStringLiteral("图片") : name);
+    UiHelpers::setupFramelessDialog(this);
+
+    const qreal dpr = image.devicePixelRatio() > 0 ? image.devicePixelRatio() : 1.0;
+    const int iw = qMax(1, qRound(image.width() / dpr));
+    const int ih = qMax(1, qRound(image.height() / dpr));
+    const int maxW = 560;
+    const int maxH = 480;
+    QSize shown(iw, ih);
+    shown.scale(maxW, maxH, Qt::KeepAspectRatio);
+    const int winW = qBound(320, shown.width() + 24, 600);
+    const int winH = qBound(280, shown.height() + 108, 620);
+    setFixedSize(winW, winH);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(name.isEmpty() ? QStringLiteral("图片") : name);
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *area = new QScrollArea(this);
+    area->setWidgetResizable(true);
+    area->setFrameShape(QFrame::NoFrame);
+    area->setStyleSheet(QStringLiteral("background:#1A1A1A; border:none;"));
+    auto *img = new QLabel(area);
+    img->setAlignment(Qt::AlignCenter);
+    img->setStyleSheet(QStringLiteral("background:transparent;"));
+    const QPixmap fit = image.scaled(qRound(shown.width() * dpr), qRound(shown.height() * dpr),
+                                     Qt::KeepAspectRatio, Qt::SmoothTransformation);
+    QPixmap display = fit;
+    display.setDevicePixelRatio(dpr);
+    img->setPixmap(display);
+    area->setWidget(img);
+    root->addWidget(area, 1);
+
+    auto *btns = new QWidget(this);
+    btns->setFixedHeight(48);
+    btns->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E5E5E5;"));
+    auto *bLay = new QHBoxLayout(btns);
+    bLay->setContentsMargins(16, 8, 16, 8);
+    auto *save = UiHelpers::ghostButton(QStringLiteral("保存"), btns);
+    save->setFixedSize(88, 32);
+    auto *fwd = UiHelpers::ghostButton(QStringLiteral("转发"), btns);
+    fwd->setFixedSize(88, 32);
+    fwd->setVisible(canForward);
+    auto *ok = UiHelpers::primaryButton(QStringLiteral("关闭"), btns);
+    ok->setFixedSize(88, 32);
+    bLay->addWidget(save);
+    if (canForward)
+        bLay->addWidget(fwd);
+    bLay->addStretch();
+    bLay->addWidget(ok);
+    root->addWidget(btns);
+
+    connect(ok, &QPushButton::clicked, this, &QDialog::accept);
+    connect(fwd, &QPushButton::clicked, this, [this]() {
+        m_forward = true;
+        accept();
+    });
+    connect(save, &QPushButton::clicked, this, [this, name, bytes]() {
+        const QString path = QFileDialog::getSaveFileName(
+            this, QStringLiteral("保存图片"), name.isEmpty() ? QStringLiteral("image.jpg") : name);
+        if (path.isEmpty())
+            return;
+        QFile f(path);
+        if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size())
+        {
+            UiHelpers::showToast(this, QStringLiteral("保存失败"), true);
+            return;
+        }
+        UiHelpers::showToast(this, QStringLiteral("已保存"));
+    });
+}

+ 18 - 0
Client/src/widgets/ImagePreviewDialog.h

@@ -0,0 +1,18 @@
+#pragma once
+
+#include <QByteArray>
+#include <QDialog>
+#include <QPixmap>
+#include <QString>
+
+class ImagePreviewDialog : public QDialog
+{
+public:
+    ImagePreviewDialog(const QPixmap &image, const QString &name, const QByteArray &bytes,
+                       QWidget *parent = nullptr, bool canForward = false);
+
+    bool wantsForward() const { return m_forward; }
+
+private:
+    bool m_forward = false;
+};

+ 150 - 0
Client/src/widgets/PromptDialog.cpp

@@ -0,0 +1,150 @@
+#include "PromptDialog.h"
+#include "FramelessHelper.h"
+#include "UiHelpers.h"
+
+#include <QFrame>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QLineEdit>
+#include <QMouseEvent>
+#include <QPushButton>
+#include <QVBoxLayout>
+
+PromptDialog::PromptDialog(QWidget *parent, const QString &title, const QString &hint,
+                           const QString &initial, int maxLen,
+                           const QString &okText, const QString &cancelText)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(title);
+    setFixedWidth(320);
+    UiHelpers::setupFramelessDialog(this);
+    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(24, 22, 24, 18);
+    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;"));
+    bodyLay->addWidget(titleLab);
+
+    if (!hint.isEmpty())
+    {
+        auto *hintLab = new QLabel(hint, body);
+        hintLab->setWordWrap(true);
+        hintLab->setAlignment(Qt::AlignCenter);
+        hintLab->setStyleSheet(QStringLiteral(
+            "font-size:13px; color:#888888; background:transparent; border:none;"));
+        bodyLay->addWidget(hintLab);
+    }
+
+    m_edit = UiHelpers::lineEdit(hint.isEmpty() ? QStringLiteral("请输入") : hint, body);
+    m_edit->setMaxLength(qMax(1, maxLen));
+    m_edit->setText(initial);
+    m_edit->setClearButtonEnabled(true);
+    bodyLay->addWidget(m_edit);
+
+    auto *line = new QFrame(this);
+    line->setFixedHeight(1);
+    line->setStyleSheet(QStringLiteral("background:#E5E5E5; border:none;"));
+
+    auto *btns = new QWidget(this);
+    btns->setFixedHeight(48);
+    auto *btnLay = new QHBoxLayout(btns);
+    btnLay->setContentsMargins(0, 0, 0, 0);
+    btnLay->setSpacing(0);
+
+    const QString btnQss = QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:none; font-size:15px; border-radius:0; }"
+        "QPushButton:hover { background:#F7F7F7; }"
+        "QPushButton:pressed { background:#EFEFEF; }");
+
+    auto *cancel = new QPushButton(cancelText, btns);
+    cancel->setCursor(Qt::PointingHandCursor);
+    cancel->setFixedHeight(48);
+    cancel->setAutoDefault(false);
+    cancel->setDefault(false);
+    cancel->setStyleSheet(btnQss + QStringLiteral("QPushButton { color:#191919; }"));
+
+    auto *split = new QFrame(btns);
+    split->setFixedWidth(1);
+    split->setStyleSheet(QStringLiteral("background:#E5E5E5; border:none;"));
+
+    auto *ok = new QPushButton(okText, btns);
+    ok->setCursor(Qt::PointingHandCursor);
+    ok->setFixedHeight(48);
+    ok->setAutoDefault(true);
+    ok->setDefault(true);
+    ok->setStyleSheet(btnQss + QStringLiteral("QPushButton { color:#07C160; }"));
+
+    btnLay->addWidget(cancel, 1);
+    btnLay->addWidget(split);
+    btnLay->addWidget(ok, 1);
+
+    root->addWidget(body);
+    root->addWidget(line);
+    root->addWidget(btns);
+
+    connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
+    connect(ok, &QPushButton::clicked, this, &QDialog::accept);
+    connect(m_edit, &QLineEdit::returnPressed, this, &QDialog::accept);
+
+    adjustSize();
+    setFixedHeight(height());
+    m_edit->setFocus();
+    m_edit->selectAll();
+}
+
+QString PromptDialog::value() const
+{
+    return m_edit ? m_edit->text() : QString();
+}
+
+bool PromptDialog::ask(QWidget *parent, const QString &title, const QString &hint,
+                       QString *text, int maxLen,
+                       const QString &okText, const QString &cancelText)
+{
+    if (!text)
+        return false;
+    PromptDialog dlg(parent, title, hint, *text, maxLen, okText, cancelText);
+    if (parent)
+    {
+        const QPoint c = parent->mapToGlobal(parent->rect().center());
+        dlg.move(c.x() - dlg.width() / 2, c.y() - dlg.height() / 2);
+    }
+    if (dlg.exec() != QDialog::Accepted)
+        return false;
+    *text = dlg.value().trimmed();
+    return true;
+}
+
+void PromptDialog::mousePressEvent(QMouseEvent *event)
+{
+    if (event->button() == Qt::LeftButton)
+    {
+        m_dragging = true;
+        m_dragOffset = event->globalPos() - frameGeometry().topLeft();
+    }
+    QDialog::mousePressEvent(event);
+}
+
+void PromptDialog::mouseMoveEvent(QMouseEvent *event)
+{
+    if (m_dragging && (event->buttons() & Qt::LeftButton))
+        move(event->globalPos() - m_dragOffset);
+    QDialog::mouseMoveEvent(event);
+}
+
+void PromptDialog::mouseReleaseEvent(QMouseEvent *event)
+{
+    m_dragging = false;
+    QDialog::mouseReleaseEvent(event);
+}

+ 33 - 0
Client/src/widgets/PromptDialog.h

@@ -0,0 +1,33 @@
+#pragma once
+
+#include <QDialog>
+#include <QPoint>
+#include <QString>
+
+class QLineEdit;
+class QMouseEvent;
+
+class PromptDialog : public QDialog
+{
+public:
+    static bool ask(QWidget *parent, const QString &title, const QString &hint,
+                    QString *text, int maxLen = 32,
+                    const QString &okText = QStringLiteral("确定"),
+                    const QString &cancelText = QStringLiteral("取消"));
+
+protected:
+    void mousePressEvent(QMouseEvent *event) override;
+    void mouseMoveEvent(QMouseEvent *event) override;
+    void mouseReleaseEvent(QMouseEvent *event) override;
+
+private:
+    PromptDialog(QWidget *parent, const QString &title, const QString &hint,
+                 const QString &initial, int maxLen,
+                 const QString &okText, const QString &cancelText);
+
+    QString value() const;
+
+    QLineEdit *m_edit = nullptr;
+    bool m_dragging = false;
+    QPoint m_dragOffset;
+};

+ 308 - 0
Client/src/widgets/TitleBar.cpp

@@ -0,0 +1,308 @@
+#include "TitleBar.h"
+#include "FramelessHelper.h"
+#include "AppTheme.h"
+
+#include <QEvent>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QMouseEvent>
+#include <QPainter>
+#include <QPushButton>
+#include <QStyle>
+
+namespace
+{
+    class CaptionButton : public QPushButton
+    {
+    public:
+        enum Kind { Min, Max, Close };
+
+        CaptionButton(Kind kind, QWidget *parent)
+            : QPushButton(parent)
+            , m_kind(kind)
+        {
+            setFixedSize(46, 32);
+            setCursor(Qt::ArrowCursor);
+            setFocusPolicy(Qt::NoFocus);
+            setFlat(true);
+            setStyleSheet(QStringLiteral("QPushButton { border:none; background:transparent; padding:0; }"));
+        }
+
+        void setLight(bool light) { m_light = light; update(); }
+        void setMaximized(bool on) { m_maximized = on; update(); }
+
+    protected:
+        void enterEvent(QEvent *) override { m_hover = true; update(); }
+        void leaveEvent(QEvent *) override { m_hover = false; update(); }
+
+        void paintEvent(QPaintEvent *) override
+        {
+            QPainter p(this);
+            p.setRenderHint(QPainter::Antialiasing, false);
+            if (m_kind == Close && m_hover)
+                p.fillRect(rect(), QColor("#E81123"));
+            else if (m_hover)
+                p.fillRect(rect(), m_light ? QColor("#E5E5E5") : QColor(0, 0, 0, 28));
+
+            const QColor c = (m_kind == Close && m_hover)
+                ? QColor("#FFFFFF")
+                : (m_light ? QColor("#4D4D4D") : QColor("#FFFFFF"));
+            QPen pen(c, 1);
+            pen.setCosmetic(true);
+            pen.setCapStyle(Qt::FlatCap);
+            pen.setJoinStyle(Qt::MiterJoin);
+            p.setPen(pen);
+            p.setBrush(Qt::NoBrush);
+
+            const int s = 10;
+            const int x = (width() - s) / 2;
+            const int y = (height() - s) / 2;
+            const int x2 = x + s - 1;
+            const int y2 = y + s - 1;
+
+            if (m_kind == Min)
+            {
+                const int mid = y + s / 2;
+                p.drawLine(x, mid, x2, mid);
+            }
+            else if (m_kind == Max)
+            {
+                if (m_maximized)
+                {
+                    p.drawLine(x + 3, y, x2, y);
+                    p.drawLine(x2, y, x2, y2 - 3);
+                    p.drawLine(x2, y2 - 3, x + 3, y2 - 3);
+                    p.drawLine(x + 3, y2 - 3, x + 3, y);
+                    p.drawLine(x, y + 3, x2 - 3, y + 3);
+                    p.drawLine(x2 - 3, y + 3, x2 - 3, y2);
+                    p.drawLine(x2 - 3, y2, x, y2);
+                    p.drawLine(x, y2, x, y + 3);
+                }
+                else
+                {
+                    p.drawLine(x, y, x2, y);
+                    p.drawLine(x2, y, x2, y2);
+                    p.drawLine(x2, y2, x, y2);
+                    p.drawLine(x, y2, x, y);
+                }
+            }
+            else
+            {
+                p.drawLine(x, y, x2, y2);
+                p.drawLine(x2, y, x, y2);
+            }
+        }
+
+    private:
+        Kind m_kind;
+        bool m_light = true;
+        bool m_hover = false;
+        bool m_maximized = false;
+    };
+}
+
+TitleBar::TitleBar(QWidget *parent)
+    : QWidget(parent)
+{
+    setFixedHeight(32);
+    setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+    m_title = new QLabel(this);
+    m_title->setStyleSheet(QStringLiteral("background:transparent; font-size:13px;"));
+
+    m_min = new CaptionButton(CaptionButton::Min, this);
+    m_max = new CaptionButton(CaptionButton::Max, this);
+    m_close = new CaptionButton(CaptionButton::Close, this);
+    m_min->hide();
+    m_max->hide();
+
+    auto *lay = new QHBoxLayout(this);
+    lay->setContentsMargins(12, 0, 0, 0);
+    lay->setSpacing(0);
+    lay->addWidget(m_title, 0, Qt::AlignVCenter);
+    lay->addStretch();
+    lay->addWidget(m_min);
+    lay->addWidget(m_max);
+    lay->addWidget(m_close);
+
+    connect(m_min, &QPushButton::clicked, this, &TitleBar::onMin);
+    connect(m_max, &QPushButton::clicked, this, &TitleBar::onMax);
+    connect(m_close, &QPushButton::clicked, this, &TitleBar::onClose);
+
+    applyTheme();
+}
+
+void TitleBar::setTitle(const QString &title)
+{
+    m_title->setText(title);
+}
+
+void TitleBar::setTheme(Theme theme)
+{
+    m_theme = theme;
+    applyTheme();
+}
+
+void TitleBar::setMaximizable(bool on)
+{
+    m_maximizable = on;
+    m_max->setVisible(on);
+    m_min->setVisible(on);
+}
+
+void TitleBar::setActionText(const QString &text)
+{
+    if (!m_action)
+    {
+        m_action = new QPushButton(this);
+        m_action->setCursor(Qt::PointingHandCursor);
+        m_action->setFlat(true);
+        m_action->setFocusPolicy(Qt::NoFocus);
+        m_action->setFixedHeight(32);
+        if (auto *lay = qobject_cast<QHBoxLayout *>(layout()))
+        {
+            const int idx = lay->indexOf(m_min);
+            lay->insertWidget(idx >= 0 ? idx : lay->count() - 1, m_action);
+        }
+        connect(m_action, &QPushButton::clicked, this, &TitleBar::actionClicked);
+        applyTheme();
+    }
+    m_action->setText(text);
+    m_action->setVisible(!text.isEmpty());
+    m_action->setFixedWidth(text.isEmpty() ? 0 : 56);
+}
+
+void TitleBar::bind(QWidget *host, bool resizable)
+{
+    m_host = host;
+    if (!(host->windowFlags() & Qt::FramelessWindowHint))
+        host->setWindowFlags(host->windowFlags() | Qt::FramelessWindowHint);
+    if (!m_helper)
+        m_helper = new FramelessHelper(host, this, resizable);
+    if (m_host)
+        m_host->installEventFilter(this);
+}
+
+void TitleBar::mousePressEvent(QMouseEvent *event)
+{
+    if (event->button() == Qt::LeftButton && m_host && !m_host->isMaximized())
+    {
+        m_dragging = true;
+        m_dragOffset = event->globalPos() - m_host->frameGeometry().topLeft();
+    }
+    QWidget::mousePressEvent(event);
+}
+
+void TitleBar::mouseMoveEvent(QMouseEvent *event)
+{
+    if (m_dragging && m_host && (event->buttons() & Qt::LeftButton))
+    {
+        if (m_host->isMaximized())
+        {
+            m_dragging = false;
+            return;
+        }
+        m_host->move(event->globalPos() - m_dragOffset);
+    }
+    QWidget::mouseMoveEvent(event);
+}
+
+void TitleBar::mouseReleaseEvent(QMouseEvent *event)
+{
+    m_dragging = false;
+    QWidget::mouseReleaseEvent(event);
+}
+
+bool TitleBar::hitButtons(const QPoint &posInHost) const
+{
+    const QPoint local = mapFrom(m_host ? m_host : parentWidget(), posInHost);
+    const QWidget *btns[] = { m_min, m_max, m_close, m_action };
+    for (const QWidget *b : btns)
+    {
+        if (b && b->isVisible() && b->geometry().contains(local))
+            return true;
+    }
+    return false;
+}
+
+bool TitleBar::hitCaption(const QPoint &posInHost) const
+{
+    const QPoint local = mapFrom(m_host ? m_host : parentWidget(), posInHost);
+    if (!rect().contains(local))
+        return false;
+    const QWidget *btns[] = { m_min, m_max, m_close, m_action };
+    for (const QWidget *b : btns)
+    {
+        if (b && b->isVisible() && b->geometry().contains(local))
+            return false;
+    }
+    return true;
+}
+
+void TitleBar::mouseDoubleClickEvent(QMouseEvent *event)
+{
+    if (event->button() == Qt::LeftButton && m_maximizable)
+        onMax();
+    QWidget::mouseDoubleClickEvent(event);
+}
+
+void TitleBar::paintEvent(QPaintEvent *)
+{
+    QPainter p(this);
+    p.fillRect(rect(), QColor(m_theme == Primary ? AppTheme::Primary : "#F5F5F5"));
+}
+
+void TitleBar::onMin()
+{
+    if (m_host)
+        m_host->showMinimized();
+}
+
+void TitleBar::onMax()
+{
+    if (!m_host || !m_maximizable)
+        return;
+    if (m_host->isMaximized())
+        m_host->showNormal();
+    else
+        m_host->showMaximized();
+    refreshMaxIcon();
+}
+
+void TitleBar::onClose()
+{
+    if (m_host)
+        m_host->close();
+}
+
+void TitleBar::refreshMaxIcon()
+{
+    if (auto *btn = static_cast<CaptionButton *>(m_max))
+        btn->setMaximized(m_host && m_host->isMaximized());
+}
+
+void TitleBar::applyTheme()
+{
+    const bool light = (m_theme == Light);
+    m_title->setStyleSheet(QStringLiteral("background:transparent; font-size:13px; color:%1;")
+        .arg(light ? QStringLiteral("#3D3D3D") : QStringLiteral("#FFFFFF")));
+    static_cast<CaptionButton *>(m_min)->setLight(light);
+    static_cast<CaptionButton *>(m_max)->setLight(light);
+    static_cast<CaptionButton *>(m_close)->setLight(light);
+    if (m_action)
+    {
+        m_action->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; font-size:13px; padding:0 8px; color:%1; }"
+            "QPushButton:hover { color:%2; }")
+            .arg(light ? QStringLiteral("#07C160") : QStringLiteral("#FFFFFF"),
+                 light ? QStringLiteral("#06AD56") : QStringLiteral("#E5E5E5")));
+    }
+    update();
+}
+
+bool TitleBar::eventFilter(QObject *watched, QEvent *event)
+{
+    if (watched == m_host && event->type() == QEvent::WindowStateChange)
+        refreshMaxIcon();
+    return QWidget::eventFilter(watched, event);
+}

+ 67 - 0
Client/src/widgets/TitleBar.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include <QWidget>
+#include <QEvent>
+#include <QMouseEvent>
+#include <QPaintEvent>
+
+class QLabel;
+class QPushButton;
+class FramelessHelper;
+
+class TitleBar : public QWidget
+{
+    Q_OBJECT
+
+public:
+    enum Theme
+    {
+        Light,
+        Primary
+    };
+
+    explicit TitleBar(QWidget *parent = nullptr);
+
+    void setTitle(const QString &title);
+    void setTheme(Theme theme);
+    void setMaximizable(bool on);
+    void setActionText(const QString &text);
+
+    // host: the frameless window; resizable: allow edge resize
+    void bind(QWidget *host, bool resizable);
+
+    bool hitCaption(const QPoint &posInHost) const;
+    bool hitButtons(const QPoint &posInHost) const;
+    int barHeight() const { return 32; }
+    FramelessHelper *framelessHelper() const { return m_helper; }
+
+signals:
+    void actionClicked();
+
+protected:
+    void mousePressEvent(QMouseEvent *event) override;
+    void mouseMoveEvent(QMouseEvent *event) override;
+    void mouseReleaseEvent(QMouseEvent *event) override;
+    void mouseDoubleClickEvent(QMouseEvent *event) override;
+    void paintEvent(QPaintEvent *event) override;
+    bool eventFilter(QObject *watched, QEvent *event) override;
+
+private:
+    void onMin();
+    void onMax();
+    void onClose();
+    void refreshMaxIcon();
+    void applyTheme();
+
+    QWidget *m_host = nullptr;
+    QLabel *m_title = nullptr;
+    QPushButton *m_min = nullptr;
+    QPushButton *m_max = nullptr;
+    QPushButton *m_close = nullptr;
+    QPushButton *m_action = nullptr;
+    Theme m_theme = Light;
+    bool m_maximizable = false;
+    bool m_dragging = false;
+    QPoint m_dragOffset;
+    FramelessHelper *m_helper = nullptr;
+};

+ 226 - 0
common/i18n.h

@@ -0,0 +1,226 @@
+#pragma once
+
+#include <cstring>
+#include <string>
+
+#include "common/protocol_def.h"
+
+namespace im {
+namespace i18n {
+
+// 语言:由 Client 连接后 SetLang 下发,存于 Session;Client 本地 [app] lang 仅决定上报值与兜底文案
+enum class Lang {
+    Zh = 0,
+    En = 1,
+};
+
+// 回包 msg / 客户端兜底文案的键
+enum class Msg {
+    Ok = 0,
+    Kicked,
+    InvalidJson,
+    NotLogin,
+    UidPasswordRequired,
+    UidOrPasswordIncorrect,
+    AccountDisabled,
+    SystemAccount,
+    IncompleteRegisterInfo,
+    NicknameTooLong,
+    BadAccountFormat,
+    WeakPassword,
+    InvalidPhone,
+    AccountExists,
+    PhoneExists,
+    BadSms,
+    RegisterFailed,
+    SceneMustBeRegisterOrReset,
+    PhoneNotRegistered,
+    SmsTooFrequent,
+    SmsSendFailed,
+    IncompleteResetInfo,
+    UserNotFound,
+    PhoneMismatch,
+    AccountNotAllowed,
+    OldPasswordRequired,
+    OldPasswordIncorrect,
+    ToUidContentRequired,
+    ContentTooLong,
+    PeerUidRequired,
+    UidRequired,
+    CannotAddYourself,
+    AlreadyFriends,
+    FriendRequestSent,
+    AddFriendNotAllowed,
+    FriendRequestPending,
+    FriendRequestMessageTooLong,
+    SearchKeywordRequired,
+    StickerNotFound,
+    DbError,
+    BadParam,
+    RequestFailed,
+    NotFriends,
+    RemarkTooLong,
+    FileTooLarge,
+    FileNotFound,
+    AvatarInvalid,
+    SignatureTooLong,
+    CannotOperateSystemFriend,
+    _Count
+};
+
+namespace detail {
+
+struct Entry {
+    const char* zh;
+    const char* en;
+};
+
+inline Lang& current()
+{
+    static Lang g = Lang::Zh;
+    return g;
+}
+
+inline const Entry& table(Msg id)
+{
+    static const Entry k[static_cast<int>(Msg::_Count)] = {
+        {"成功", "OK"},
+        {"账号已在其他设备登录", "Signed in on another device"},
+        {"请求数据无效", "Invalid request data"},
+        {"未登录", "Not logged in"},
+        {"请输入账号和密码", "Account and password required"},
+        {"账号或密码错误", "Incorrect account or password"},
+        {"账号已禁用", "Account disabled"},
+        {"系统账号不可登录", "System account"},
+        {"注册信息不完整", "Incomplete register info"},
+        {"昵称过长", "Nickname too long"},
+        {"账号须为4-16位字母或数字", "Account must be 4-16 letters or digits"},
+        {"密码须为6-20个字符", "Password must be 6-20 characters"},
+        {"手机号无效", "Invalid phone number"},
+        {"账号已存在", "Account already exists"},
+        {"手机号已注册", "Phone already registered"},
+        {"验证码错误或已过期", "Invalid or expired SMS code"},
+        {"注册失败", "Register failed"},
+        {"场景须为 register 或 reset", "Scene must be register or reset"},
+        {"手机号未注册", "Phone not registered"},
+        {"验证码发送过于频繁", "SMS too frequent"},
+        {"短信发送失败", "SMS send failed"},
+        {"重置信息不完整", "Incomplete reset info"},
+        {"用户不存在", "User not found"},
+        {"手机号与账号不匹配", "Phone mismatch"},
+        {"账号不允许此操作", "Account not allowed"},
+        {"请输入原密码和新密码", "Old password and password required"},
+        {"原密码错误", "Old password incorrect"},
+        {"请指定接收方和消息内容", "to_uid and content required"},
+        {"消息内容过长", "Content too long"},
+        {"请指定会话对方", "peer_uid required"},
+        {"请指定用户", "uid required"},
+        {"不能添加自己为好友", "Cannot add yourself"},
+        {"已经是好友", "Already friends"},
+        {"好友申请已发送,等待对方同意", "Friend request sent, waiting for approval"},
+        {"对方不允许添加好友", "This user does not accept friend requests"},
+        {"已发送过申请,请等待对方处理", "Request already sent"},
+        {"验证信息过长", "Verification message too long"},
+        {"请输入账号或手机号", "Account or phone required"},
+        {"表情不存在", "Sticker not found"},
+        {"服务暂时不可用", "Service temporarily unavailable"},
+        {"参数错误", "Invalid parameter"},
+        {"请求失败", "Request failed"},
+        {"还不是好友", "Not friends"},
+        {"备注过长", "Remark too long"},
+        {"文件过大", "File too large"},
+        {"文件不存在", "File not found"},
+        {"头像无效", "Invalid avatar"},
+        {"签名过长", "Signature too long"},
+        {"系统账号不支持此操作", "System account does not support this action"},
+    };
+    const int i = static_cast<int>(id);
+    if (i < 0 || i >= static_cast<int>(Msg::_Count)) {
+        return k[static_cast<int>(Msg::RequestFailed)];
+    }
+    return k[i];
+}
+
+}  // namespace detail
+
+inline Lang lang()
+{
+    return detail::current();
+}
+
+inline void set_lang(Lang l)
+{
+    detail::current() = l;
+}
+
+inline Lang parse_lang(const char* s)
+{
+    if (s == nullptr || s[0] == '\0') {
+        return Lang::Zh;
+    }
+    if ((s[0] == 'e' || s[0] == 'E') && (s[1] == 'n' || s[1] == 'N')) {
+        return Lang::En;
+    }
+    return Lang::Zh;
+}
+
+inline Lang parse_lang(const std::string& s)
+{
+    return parse_lang(s.c_str());
+}
+
+inline const char* text(Msg id, Lang l)
+{
+    const detail::Entry& e = detail::table(id);
+    return l == Lang::En ? e.en : e.zh;
+}
+
+inline const char* text(Msg id)
+{
+    return text(id, lang());
+}
+
+// 按错误码兜底(客户端在服务端未带详细 msg 时使用)
+inline Msg msg_of_err(int code)
+{
+    switch (code) {
+    case Err::Ok:
+        return Msg::Ok;
+    case Err::BadParam:
+        return Msg::BadParam;
+    case Err::NotLogin:
+        return Msg::NotLogin;
+    case Err::AuthFail:
+        return Msg::UidOrPasswordIncorrect;
+    case Err::NotFound:
+        return Msg::UserNotFound;
+    case Err::Exists:
+        return Msg::AccountExists;
+    case Err::PhoneExists:
+        return Msg::PhoneExists;
+    case Err::BadSms:
+        return Msg::BadSms;
+    case Err::WeakPass:
+        return Msg::WeakPassword;
+    case Err::Forbidden:
+        return Msg::AccountNotAllowed;
+    case Err::DbError:
+        return Msg::DbError;
+    case Err::BadAccount:
+        return Msg::BadAccountFormat;
+    case Err::RateLimit:
+        return Msg::SmsTooFrequent;
+    case Err::Disabled:
+        return Msg::AccountDisabled;
+    default:
+        return Msg::RequestFailed;
+    }
+}
+
+inline const char* err_text(int code)
+{
+    return text(msg_of_err(code));
+}
+
+}  // namespace i18n
+}  // namespace im

+ 141 - 0
common/protocol_def.h

@@ -0,0 +1,141 @@
+#pragma once
+
+#include <cstdint>
+
+namespace im {
+
+// Client / Server 共用的协议常量。编解码、JSON、网络实现仍各放各工程。
+
+enum class Type : uint16_t {
+    System  = 1,  // 心跳、被踢等
+    Account = 2,  // 登录/注册/密码/隐私
+    Friend  = 3,  // 好友
+    Message = 4,  // 会话与聊天
+    Sticker = 5,  // 表情包
+};
+
+enum class Cmd : uint16_t {
+    Heartbeat            = 1,
+    Login                = 2,
+    Logout               = 3,
+    Chat                 = 4,
+    Register             = 5,
+    SendCode             = 6,
+    ResetPassword        = 7,
+    ChangePassword       = 8,
+    FriendList           = 9,
+    ConvList             = 10,
+    MsgHistory           = 11,
+    AddFriend            = 12,
+    SetLang              = 13,
+    SearchUser           = 14,
+    FriendRequestList    = 15,
+    HandleFriendRequest  = 16,
+    GetPrivacy           = 17,
+    SetPrivacy           = 18,
+    StickerPackList      = 19,
+    StickerPack          = 20,
+    GetSticker           = 21,
+    DeleteConv           = 22,
+    SetUnread            = 23,
+    DeleteFriend         = 24,
+    SetRemark            = 25,
+    GetProfile           = 26,
+    SetProfile           = 27,
+    GetAvatar            = 28,
+    PutFile              = 29,
+    GetFile              = 30,
+    ChatNotify           = 100,
+    Kicked               = 101,
+    FriendRequestNotify  = 102,
+    FriendAcceptedNotify = 103,
+};
+
+namespace Err {
+constexpr int Ok          = 0;
+constexpr int BadParam    = 1;
+constexpr int NotLogin    = 2;
+constexpr int AuthFail    = 3;
+constexpr int NotFound    = 4;
+constexpr int Exists      = 5;
+constexpr int PhoneExists = 6;
+constexpr int BadSms      = 7;
+constexpr int WeakPass    = 8;
+constexpr int Forbidden   = 9;
+constexpr int DbError     = 10;
+constexpr int BadAccount  = 11;
+constexpr int RateLimit   = 12;
+constexpr int Disabled    = 13;
+}  // namespace Err
+
+namespace MsgType {
+constexpr int Text    = 1;
+constexpr int Sticker = 2;
+constexpr int File    = 3;
+constexpr int Card    = 4;
+}
+
+namespace FriendRel {
+constexpr int None     = 0;  // 非好友
+constexpr int Pending  = 1;  // 已发出申请,待对方同意
+constexpr int Friend   = 2;  // 已是好友
+constexpr int Incoming = 3;  // 对方已申请加我
+}
+
+constexpr uint32_t kHeaderSize = 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";
+    }
+    return "?";
+}
+
+inline const char* cmd_name(uint16_t cmd)
+{
+    switch (static_cast<Cmd>(cmd)) {
+    case Cmd::Heartbeat:            return "Heartbeat";
+    case Cmd::Login:                return "Login";
+    case Cmd::Logout:               return "Logout";
+    case Cmd::Chat:                 return "Chat";
+    case Cmd::Register:             return "Register";
+    case Cmd::SendCode:             return "SendCode";
+    case Cmd::ResetPassword:        return "ResetPassword";
+    case Cmd::ChangePassword:       return "ChangePassword";
+    case Cmd::FriendList:           return "FriendList";
+    case Cmd::ConvList:             return "ConvList";
+    case Cmd::MsgHistory:           return "MsgHistory";
+    case Cmd::AddFriend:            return "AddFriend";
+    case Cmd::SetLang:              return "SetLang";
+    case Cmd::SearchUser:           return "SearchUser";
+    case Cmd::FriendRequestList:    return "FriendRequestList";
+    case Cmd::HandleFriendRequest:  return "HandleFriendRequest";
+    case Cmd::GetPrivacy:           return "GetPrivacy";
+    case Cmd::SetPrivacy:           return "SetPrivacy";
+    case Cmd::StickerPackList:      return "StickerPackList";
+    case Cmd::StickerPack:          return "StickerPack";
+    case Cmd::GetSticker:           return "GetSticker";
+    case Cmd::DeleteConv:           return "DeleteConv";
+    case Cmd::SetUnread:            return "SetUnread";
+    case Cmd::DeleteFriend:         return "DeleteFriend";
+    case Cmd::SetRemark:            return "SetRemark";
+    case Cmd::GetProfile:           return "GetProfile";
+    case Cmd::SetProfile:           return "SetProfile";
+    case Cmd::GetAvatar:            return "GetAvatar";
+    case Cmd::PutFile:              return "PutFile";
+    case Cmd::GetFile:              return "GetFile";
+    case Cmd::ChatNotify:           return "ChatNotify";
+    case Cmd::Kicked:               return "Kicked";
+    case Cmd::FriendRequestNotify:  return "FriendRequestNotify";
+    case Cmd::FriendAcceptedNotify: return "FriendAcceptedNotify";
+    }
+    return "?";
+}
+
+}  // namespace im

+ 41 - 0
docs/README.md

@@ -0,0 +1,41 @@
+# 文档
+
+| 文档 | 用途 |
+|------|------|
+| [protocol.md](protocol.md) | 客户端 / 服务端 TCP 对接协议(命令、字段、错误码) |
+| [../server/sql/init.sql](../server/sql/init.sql) | 新库建表 |
+| [../server/sql/migrate.sql](../server/sql/migrate.sql) | 已有库升级 |
+
+## Client 功能与 Server 命令
+
+| 客户端 | 命令 |
+|--------|------|
+| 登录 | `Account` / `Login(2)` |
+| 注册账号 / 获取验证码 | `Account` / `SendCode(6)` → `Register(5)` |
+| 忘记密码 | `Account` / `SendCode(6)` scene=`reset` → `ResetPassword(7)` |
+| 修改密码 | `Account` / `ChangePassword(8)` |
+| 退出登录 | `Account` / `Logout(3)` |
+| 消息列表 | `Message` / `ConvList(10)` |
+| 打开会话 / 历史 | `Message` / `MsgHistory(11)` |
+| 发送 | `Message` / `Chat(4)`,收消息看推送 `ChatNotify(100)` |
+| 聊天图片/文件 | `Message` / `PutFile(29)` 取 COS 临时 token → 客户端直传 COS → `Chat(4)` `msg_type=3`;下载 `GetFile(30)` 或 CDN |
+| 好友列表 | `Friend` / `FriendList(9)` |
+| 搜索/添加好友 | `Friend` / `SearchUser(14)` → `AddFriend(12)`,对方 `HandleFriendRequest(16)` |
+| 隐私设置 | `Account` / `GetPrivacy(17)` `SetPrivacy(18)` |
+| 表情包 | `Sticker` / `StickerPackList(19)` `StickerPack(20)` `GetSticker(21)`,发消息 `msg_type=2` |
+| 心跳 | `System` / `Heartbeat(1)` |
+| 被挤下线 | `System` 推送 `Kicked(101)` |
+
+## 服务端
+
+```bash
+cd server
+mkdir -p build && cd build
+cmake .. -DCMAKE_BUILD_TYPE=Release && cmake --build .
+./server --init-db ../config/config.ini   # 首次:建库建表后启动
+./server ../config/config.ini             # 之后直接启动
+```
+
+监听地址、包长、MySQL、验证码回显见 `server/config/config.ini`。`[sms] echo_code` 联调可保持 `1`,生产改为 `0`。
+
+客户端按上表对接:登录用 uid,会话/好友/消息走 ConvList、FriendList、MsgHistory、Chat,收消息处理 ChatNotify,被踢处理 Kicked。

+ 664 - 0
docs/protocol.md

@@ -0,0 +1,664 @@
+# IM TCP 对接协议
+
+默认监听地址以 `server/config/config.ini` `[server]` 为准(当前 `0.0.0.0:6000`)。传输为 **裸 TCP**,应用层自行组包,**不是** HPSocket PACK 模型。
+
+本文与 Qt 客户端界面一一对应:登录、注册、验证码、找回/修改密码、会话列表、好友列表、发消息、退出登录。
+
+## 1. 数据包
+
+每个消息 = **12 字节包头 + body**。多包可粘在同一 TCP 流里,按 `length` 切包。
+
+| 偏移 | 长度 | 字段 | 说明 |
+|------|------|------|------|
+| 0 | 2 | `magic` | 固定 `0x494D`(ASCII `IM`),配置里写十六进制字符串 `0x494D` |
+| 2 | 1 | `type` | 业务大类,见第 2 节 |
+| 3 | 1 | `cmd` | 该 Type 下的命令号 |
+| 4 | 4 | `seq` | 调用方自增序号,**响应原样带回**;服务端推送为 `0` |
+| 8 | 4 | `length` | **仅 body 字节数**,不含包头 |
+| 12 | `length` | `body` | UTF-8 JSON,可为 0 字节 |
+
+- 字节序:**大端(网络序)**
+- `length` 上限:`config.ini` `[protocol] max_packet`,默认 `1048576`。聊天图片/文件走 COS 直传,不再占用 TCP 包体。
+- `magic` 不对,或 `length > max_packet`:**服务端立即断开**
+- 未知 `type` / `cmd`:不回包、不断开(仅打日志)
+
+### 1.1 组包伪代码
+
+```
+out = []
+out += be16(0x494D)
+out += be8(type)
+out += be8(cmd)
+out += be32(seq)
+out += be32(len(body))
+out += body   # UTF-8 bytes
+```
+
+### 1.2 收包
+
+1. 至少读满 12 字节
+2. 校验 `magic == 0x494D`
+3. 再读 `length` 字节 body
+4. 用 `seq` 匹配请求;可并发多个未完成请求
+5. 推送包 `seq=0`,按 `type`+`cmd` 处理,不要当请求应答
+
+## 2. 命令一览
+
+线上为 **1 字节 type + 1 字节 cmd**(原 16 位 cmd 槽拆开)。Dispatcher 按 `type` 进业务 Handler,Handler 再解析 `cmd`。
+
+| Type | 值 | Handler | Cmd |
+|------|----|---------|-----|
+| System | 1 | 系统 | Heartbeat(1),SetLang(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) |
+
+与 Client 界面的对应关系:
+
+| cmd | 名称 | 登录 | Client | 说明 |
+|-----|------|------|--------|------|
+| 1 | Heartbeat | 否 | 建议 30s 一次 | 心跳 |
+| 2 | Login | 否 | 登录窗 | 账号(username)+ 密码登录 |
+| 3 | Logout | 是 | 设置 → 退出登录 | 清会话,不断开 TCP |
+| 4 | Chat | 是 | 聊天输入框发送 | 文本 / 表情 / 文件 / 名片 |
+| 5 | Register | 否 | 注册账号 | 注册 |
+| 6 | SendCode | 否 | 获取验证码 | 短信验证码 |
+| 7 | ResetPassword | 否 | 忘记密码 | 用验证码重置 |
+| 8 | ChangePassword | 是 | 设置 → 通用 → 修改密码 | 校验旧密码后改密 |
+| 9 | FriendList | 是 | 好友页 | 好友列表 |
+| 10 | ConvList | 是 | 消息页会话列表 | 会话摘要 + 未读 |
+| 11 | MsgHistory | 是 | 点开会话 | 历史消息,并清未读 |
+| 12 | AddFriend | 是 | 好友页 + | 按账号发起申请,对方同意后才成为好友 |
+| 14 | SearchUser | 是 | 添加好友对话框 | 按账号/手机号搜索 |
+| 15 | FriendRequestList | 是 | 新的朋友 | 待处理申请 |
+| 16 | HandleFriendRequest | 是 | 新的朋友 同意/拒绝 | 1同意 2拒绝 |
+| 17 | GetPrivacy | 是 | 设置 → 隐私 | 读取隐私开关 |
+| 18 | SetPrivacy | 是 | 设置 → 隐私 | 保存隐私开关 |
+| 19 | StickerPackList | 是 | 聊天表情面板 | 表情包列表 |
+| 20 | StickerPack | 是 | 聊天表情面板 | 某包下表情元数据 |
+| 21 | GetSticker | 是 | 表情缓存 | 下载表情图片 |
+| 22 | DeleteConv | 是 | 会话右键 → 删除 | 删除自己的会话摘要,并永久删除双方聊天记录 |
+| 23 | SetUnread | 是 | 会话右键 → 已读/未读 | `unread=0` 已读,`unread=1` 未读 |
+| 24 | DeleteFriend | 是 | 好友右键 → 删除 | 双向解除好友,并永久删除双方聊天记录 |
+| 25 | SetRemark | 是 | 好友右键 → 备注 | 只改自己这一侧的备注 |
+| 26 | GetProfile | 是 | 好友右键 → 查看名片 | 按 uid 拉资料 |
+| 27 | SetProfile | 是 | 设置 → 资料 | 改昵称、签名、头像 |
+| 28 | GetAvatar | 是 | 头像缓存 | 按 uid 下载头像 JPEG |
+| 29 | PutFile | 是 | 聊天 → 图片/文件 | 申请 COS 临时上传凭证,返回 file_id + token |
+| 30 | GetFile | 是 | 点击文件气泡 | 按 file_id 下载 |
+| 100 | ChatNotify | 推送 | 聊天区收消息 | 对方在线时推送 |
+| 101 | Kicked | 推送 | 被挤下线 | 同一账号在别处登录 |
+| 102 | FriendRequestNotify | 推送 | 好友申请提示 | 收到加好友申请 |
+| 103 | FriendAcceptedNotify | 推送 | 好友同意提示 | 对方同意申请 |
+
+未登录可发:Heartbeat / SetLang / Login / Register / SendCode / ResetPassword。
+连接建立后客户端应立刻发送 SetLang,服务端按该连接语言返回 `msg`,并在应答中下发 `[limits]`(账号/密码长度、文件头像上限等)。客户端不再在本地 `config.ini` 保留这些限制。其余请求若未登录,回 `code=2`。
+
+## 3. 统一响应
+
+请求的应答 body 均为 JSON 对象:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `code` | number | `0` 成功,非 0 失败,见 3.1 |
+| `msg` | string | 文本说明 |
+| 其它 | — | 按命令扩展 |
+
+响应 `type`、`cmd`、`seq` 与请求相同(推送除外)。
+
+### 3.1 错误码
+
+| code | 含义 |
+|------|------|
+| 0 | 成功 |
+| 1 | 参数/JSON 错误 |
+| 2 | 未登录 |
+| 3 | 账号或密码错误 / 手机号不匹配 |
+| 4 | 用户不存在 |
+| 5 | 账号已存在 |
+| 6 | 手机号已注册 |
+| 7 | 验证码错误或过期 |
+| 8 | 密码长度不符合 6-20 |
+| 9 | 不允许(系统账号、加自己等) |
+| 10 | 数据库不可用或 SQL 失败 |
+| 11 | 账号格式不符合 4-16 位字母或数字 |
+| 12 | 验证码发送过于频繁 |
+| 13 | 账号已禁用 |
+
+字段:登录用 `uid`;注册仍用 `account` 作为用户名(展示/加好友资料,不用于登录)。
+
+---
+
+## 4. 命令详情
+
+### 4.1 Heartbeat(cmd=1)
+
+请求 body:空,或任意 JSON(服务端不解析)。
+
+```json
+{"code":0,"msg":"pong","ts":1788146838644}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `ts` | number | 服务端毫秒时间戳 |
+
+
+### 4.1b SetLang(cmd=13)
+
+Type=`System`。连接成功后立即发送,设置本连接回包语言。
+
+请求:
+```json
+{"lang":"zh"}
+```
+`lang`:`zh` 或 `en`。
+
+成功:
+```json
+{
+  "code":0,
+  "msg":"成功",
+  "lang":"zh",
+  "limits":{
+    "account_min":4,
+    "account_max":16,
+    "password_min":6,
+    "password_max":20,
+    "phone_len":11,
+    "nickname_max":32,
+    "signature_max":64,
+    "content_max":4096,
+    "preview_max":120,
+    "history_default":50,
+    "history_max":100,
+    "avatar_max":81920,
+    "file_max":524288,
+    "code_len":6
+  },
+    "cdn":{
+      "domain":"https://cdn.example.com"
+    },
+    "update":{
+      "version":"1.0.1",
+      "url":"https://cdn.example.com/NIM-1.0.1.zip",
+      "notes":"修复文件上传"
+    }
+}
+```
+
+`limits` 与服务端 `config.ini` 的 `[limits]` 以及 `[sms] code_len` 一致。客户端用其做输入长度和本地预校验;最终仍以服务端校验为准。
+
+`cdn.domain` 只来自 `[cdn] domain`,不会按 COS 自动推导。未配置时下发空串,客户端不走 HTTP 下载。
+
+`update` 来自服务端 `[update]`:`version` 为最新客户端版本,`url` 为 ZIP 安装包地址,`notes` 为说明。客户端用本地 `[app] version` 比较,更高则提示更新。ZIP 须把安装目录内容放在根(`NIM.exe` 在压缩包根目录);客户端先下载 ZIP,再复制一份自身到临时目录并以 `--apply-update` 用自带 `3rdparty/7z/x64/7za.exe` 解压覆盖原目录(失败再回退系统 tar / Expand-Archive),然后拉起新程序并清理临时副本。`url` 为空时客户端忽略。
+
+
+### 4.2 Login(cmd=2)
+
+对应登录窗:账号(`im_user.username`)+ 密码。请求字段为 `account`(兼容 `username`),不再使用 `uid` 登录。
+
+请求:
+
+```json
+{"account":"alice","password":"******"}
+```
+
+成功:
+
+```json
+{"code":0,"msg":"ok","uid":10002,"account":"alice","nickname":"Alice","phone":"13800138000","signature":"","avatar_ver":0}
+```
+
+同一账号在新连接登录成功时,旧连接会收到 **Kicked** 推送并被断开。系统账号(文件传输助手)不可登录。
+
+### 4.3 Logout(cmd=3)
+
+请求 body 可空。成功后该连接变为未登录,TCP 不断开。关连接无需发 Logout。
+
+### 4.4 Register(cmd=5)
+
+对应注册窗:昵称、账号、手机号、验证码、密码。须先对同一手机号发送 `scene=register` 的验证码。
+
+请求:
+
+```json
+{
+  "nickname":"Alice",
+  "account":"alice",
+  "phone":"13800138000",
+  "sms_code":"123456",
+  "password":"******"
+}
+```
+
+约束(与客户端提示一致,长度见 `[limits]`):
+
+- 账号:4-16 位字母或数字
+- 密码:6-20 位
+- 手机号:11 位,以 `1` 开头
+
+成功:
+
+```json
+{"code":0,"msg":"ok","uid":10002,"account":"alice","nickname":"Alice"}
+```
+
+注册成功后自动加好友 **文件传输助手**(`uid=1`)。
+
+### 4.5 SendCode(cmd=6)
+
+对应「获取验证码」。
+
+```json
+{"phone":"13800138000","scene":"register"}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `scene` | `register` 注册;`reset` 找回密码。缺省为 `register` |
+| `reset` | 要求该手机号已注册 |
+
+成功:
+
+```json
+{"code":0,"msg":"ok","ttl":300,"sms_code":"123456"}
+```
+
+`sms_code` 仅在 `config.ini` `[sms] echo_code=1` 时返回,便于客户端联调。生产环境必须改为 `0`。默认 60 秒内不可对同一手机号+场景重复发送。
+
+### 4.6 ResetPassword(cmd=7)
+
+对应忘记密码:uid + 绑定手机号 + 验证码 + 新密码。验证码 `scene` 必须是 `reset`。
+
+```json
+{"uid":10002,"phone":"13800138000","sms_code":"123456","password":"newpass"}
+```
+
+### 4.7 ChangePassword(cmd=8)
+
+对应设置里的修改密码,须已登录。
+
+```json
+{"old_password":"oldpass","password":"newpass"}
+```
+
+### 4.8 FriendList(cmd=9)
+
+对应左侧「好友」列表。请求 body 可空。
+
+```json
+{
+  "code":0,
+  "msg":"ok",
+  "pending":1,
+  "list":[
+    {"uid":1,"account":"filehelper","nickname":"文件传输助手","signature":"文件助手","online":0},
+    {"uid":10003,"account":"bob","nickname":"Bob","signature":"","online":1}
+  ]
+}
+```
+
+`online`:`1` 在线,`0` 离线。`pending` 为待处理的好友申请数。
+
+### 4.9 SearchUser(cmd=14)
+
+按账号精确搜索,11 位手机号则按手机号搜。受对方隐私设置约束:不允许被搜到时返回用户不存在。须已登录。
+
+```json
+{"keyword":"alice"}
+```
+
+成功:
+
+```json
+{
+  "code":0,
+  "uid":10003,
+  "account":"alice",
+  "nickname":"Alice",
+  "signature":"",
+  "relation":0,
+  "allow_add":1
+}
+```
+
+`relation`:`0` 非好友,`1` 已发出申请,`2` 已是好友,`3` 对方已申请加我。
+
+### 4.10 AddFriend(cmd=12)
+
+按账号或 uid 发起好友申请,须对方同意后才成为好友。对方在线会收到 `FriendRequestNotify` 推送。
+
+```json
+{"account":"alice","message":"我是同事,请通过"}
+```
+
+`message` 为选填验证信息,最长约 50 字。
+
+成功时 `relation=1` 表示申请已发出。若对方刚好也申请了自己,则直接成为好友(`relation=2`)。
+
+### 4.11 FriendRequestList(cmd=15) / HandleFriendRequest(cmd=16)
+
+列出待处理申请;`action=1` 同意,`action=2` 拒绝。同意后双向写入好友关系,并向对方推送 `FriendAcceptedNotify`。
+
+```json
+{"id":12,"action":1}
+```
+
+### 4.12 GetPrivacy(cmd=17) / SetPrivacy(cmd=18)
+
+设置是否允许通过账号、手机号被搜索,以及是否允许他人添加。缺省字段保持原值。
+
+```json
+{"allow_search_account":1,"allow_search_phone":1,"allow_add_friend":1}
+```
+
+### 4.13 StickerPackList(cmd=19) / StickerPack(cmd=20) / GetSticker(cmd=21)
+
+Type=`Sticker`(5)。表情包由服务端下发,客户端按 `sha256` 缓存。发消息时 `msg_type=2`,`content` 为 `{"pack_id":1,"code":"smile"}`。
+
+GetSticker 成功:
+
+```json
+{"id":1,"pack_id":1,"code":"smile","name":"微笑","sha256":"...","mime":"image/png","data":"<base64>"}
+```
+
+### 4.10 ConvList(cmd=10)
+
+对应左侧「消息」会话列表。
+
+```json
+{
+  "code":0,
+  "msg":"ok",
+  "list":[
+    {
+      "peer_uid":10003,
+      "account":"bob",
+      "nickname":"Bob",
+      "signature":"",
+      "last_msg_id":88,
+      "preview":"hello",
+      "ts":1788146838644,
+      "unread":2
+    }
+  ]
+}
+```
+
+`unread` 对应会话头像角标。
+
+### 4.11 MsgHistory(cmd=11)
+
+点开会话时拉取历史,并将会话未读清零。
+
+```json
+{"peer_uid":10003,"last_id":0,"limit":50}
+```
+
+| 字段 | 说明 |
+|------|------|
+| `last_id` | `0` 表示最新一页;大于 0 时拉比该 id 更早的消息 |
+| `limit` | 默认与上限见 `[limits] history_default` / `history_max`(50 / 100) |
+
+```json
+{
+  "code":0,
+  "msg":"ok",
+  "list":[
+    {"msg_id":87,"from_uid":10003,"to_uid":10002,"msg_type":1,"content":"hi","ts":1788146800000},
+    {"msg_id":88,"from_uid":10002,"to_uid":10003,"msg_type":1,"content":"hello","ts":1788146838644}
+  ]
+}
+```
+
+列表按时间正序(旧 → 新)。`from_uid` 等于当前用户即自己发的气泡。
+
+### 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 下发给客户端)。
+
+```json
+{"to_uid":10003,"msg_type":1,"content":"hello"}
+```
+
+成功:
+
+```json
+{"code":0,"msg":"ok","msg_id":88,"ts":1788146838644}
+```
+
+对方在线时,服务端另推 **ChatNotify**(cmd=100,seq=0):
+
+```json
+{
+  "msg_id":88,
+  "from_uid":10002,
+  "from_account":"alice",
+  "from_nickname":"Alice",
+  "to_uid":10003,
+  "msg_type":1,
+  "content":"hello",
+  "ts":1788146838644
+}
+```
+
+双方会话预览会更新;接收方 `unread+1`。
+
+### 4.14 DeleteConv(cmd=22)
+
+删除当前用户的会话列表项,并 **永久删除** `im_message` 中双方之间的全部聊天记录。再次聊天会从空白开始。
+
+```json
+{"peer_uid":10003}
+```
+
+### 4.15 SetUnread(cmd=23)
+
+```json
+{"peer_uid":10003,"unread":0}
+```
+
+`unread=0` 标记已读;`unread>=1` 标记未读。点开会话拉历史(MsgHistory)仍会清未读。
+
+### 4.16 DeleteFriend(cmd=24)
+
+```json
+{"uid":10003}
+```
+
+删除 `im_friend` 双向关系,并永久删除双方聊天记录与会话摘要。不是好友时 `code=4`。
+
+### 4.17 SetRemark(cmd=25)
+
+```json
+{"uid":10003,"remark":"老王"}
+```
+
+`remark` 最长 32 字节,空字符串表示清除备注。会话列表展示优先用备注。
+
+### 4.18 GetProfile(cmd=26)
+
+```json
+{"uid":10003}
+```
+
+成功:
+
+```json
+{"code":0,"uid":10003,"account":"bob","nickname":"Bob","signature":"","avatar_ver":1,"relation":2,"remark":"老王"}
+```
+
+### 4.20 SetProfile(cmd=27)
+
+改自己的昵称、签名;可选带 JPEG 头像(base64)。头像须为 JPEG,体积不超过 `avatar_max`。
+
+```json
+{"nickname":"Alice","signature":"hello","avatar":"/9j/..."}
+```
+
+成功返回 `nickname`、`signature`、`avatar_ver`。
+
+### 4.21 GetAvatar(cmd=28)
+
+```json
+{"uid":10003}
+```
+
+成功:`uid`、`ver`、`mime`、`data`(JPEG base64)。未设置头像时 `code=4`。
+
+### 4.22 PutFile(cmd=29)
+
+聊天图片/文件**不再经 TCP 上传**。PutFile 只申请对象键和限时凭证,客户端用返回的 `token` 直接 PUT 到腾讯云 COS,然后再用 Chat `msg_type=3` 发送该 `file_id`。
+
+请求(只传元数据,不要带 `data`):
+
+```json
+{"name":"a.pdf","mime":"application/pdf","size":1024}
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `name` | string | 文件名,服务端会去掉路径并截断 |
+| `mime` | string | 可选,缺省 `application/octet-stream` |
+| `size` | number | **必填**,字节数,`1 … file_max` |
+| `sha256` | string | 可选,十六进制,最长 64 |
+
+成功:
+
+```json
+{
+  "code":0,
+  "msg":"ok",
+  "file_id":12,
+  "name":"a.pdf",
+  "size":1024,
+  "mime":"application/pdf",
+  "url":"im/files/12.pdf",
+  "token":{
+    "tmp_secret_id":"AKIDxxx",
+    "tmp_secret_key":"xxx",
+    "session_token":"xxx",
+    "start_time":1788146838,
+    "expired_time":1788148638,
+    "bucket":"examplebucket-1250000000",
+    "region":"ap-beijing",
+    "key":"im/files/12.pdf",
+    "host":"examplebucket-1250000000.cos.ap-beijing.myqcloud.com",
+    "upload_url":"https://examplebucket-1250000000.cos.ap-beijing.myqcloud.com/im/files/12.pdf",
+    "method":"PUT",
+    "authorization":"q-sign-algorithm=sha1&q-ak=...&q-signature=...",
+    "headers":{
+      "Host":"examplebucket-1250000000.cos.ap-beijing.myqcloud.com",
+      "Authorization":"q-sign-algorithm=sha1&...",
+      "Content-Type":"application/pdf",
+      "Content-Length":"1024",
+      "x-cos-security-token":"xxx"
+    }
+  }
+}
+```
+
+客户端上传(二选一,推荐 HTTP PUT):
+
+1. **HTTP PUT**(无需 COS SDK):`PUT token.upload_url`,请求体为原始文件字节,请求头必须与 `token.headers` **逐项一致**(含 `Content-Length`、`Content-Type`;有 `session_token` 时必须带 `x-cos-security-token`)。不要改用 chunked,不要增删已签名头。HTTP `200`/`204` 即成功。
+2. **COS SDK**:用 `tmp_secret_id` / `tmp_secret_key` / `session_token`,`region`+`bucket`,对象键 `key`,PutObject 上传。
+
+`url` 仍是对象键,与 CDN 拼接方式不变:`cdn.domain + "/" + url`。凭证默认约 30 分钟有效。STS 申请失败时服务端会回退为永久密钥签发的限时 PUT 签名,此时 `tmp_secret_*` / `session_token` 可能为空,客户端仍可用 `headers` + `upload_url` 直传。
+
+对象上传完成前不要发 Chat;服务端对 `msg_type=3` 会 HEAD COS,对象不存在则 `code=4`。
+
+GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客户端应走 HTTP 下载,不要再经 TCP 拉文件。
+
+### 4.23 GetFile(cmd=30)
+
+```json
+{"file_id":12}
+```
+
+成功:`file_id`、`name`、`size`、`mime`、`data`(base64)。
+
+### 4.19 Kicked(cmd=101,推送)
+
+```json
+{"code":0,"msg":"kicked"}
+```
+
+收到后应回到登录窗。随后 TCP 会被服务端断开。
+
+## 5. 连接行为
+
+- 接入后即可发包,无握手包
+- 建议 30s 内发一次心跳,避免中间设备掐连接
+- 推送复用同一包格式:`ChatNotify=100`、`Kicked=101`,`seq=0`
+- 关连接无需发 Logout;Logout 只清登录态
+- 密码在服务端以 SHA-256 十六进制存储,传输仍为明文 JSON,生产环境应叠 TLS 或另行加密
+
+## 6. 配置与库表
+
+客户端只关心 `[server]` 的 `host`/`port` 和 `[protocol]` 的 `magic`/`max_packet`。`magic` 为十六进制字符串(如 `0x494D`)。改 `magic` 后客户端必须同步。
+
+资源下载域名见 `[cdn] domain`,由 SetLang 原样下发;未配置则为空。COS 对象键前缀见 `[cos] prefix`。聊天图片/文件由客户端持 PutFile 下发的临时 token 直传 COS,字节不经过 IM 服务端。
+
+长度、条数、验证码间隔等见 `[limits]`、`[sms]`。
+
+`[sms] echo_code`:开发联调为 `1` 时,SendCode 响应带回 `sms_code`;上线改为 `0`。
+
+新库执行 [`server/sql/init.sql`](../server/sql/init.sql)。已有 `im_user` / `im_message` 的库执行 [`server/sql/migrate.sql`](../server/sql/migrate.sql)(已存在的列报错可忽略)。
+
+| 表 | 用途 |
+|----|------|
+| `im_user` | 用户(含手机号、签名;`status`:1 正常 / 0 禁用 / 2 系统账号) |
+| `im_sms_code` | 验证码 |
+| `im_friend` | 双向好友(含 `remark` 备注) |
+| `im_message` | 消息 |
+| `im_conversation` | 会话摘要与未读 |
+
+`uid=1` 为文件传输助手(`username=filehelper`,不可登录)。
+
+## 7. 示例
+
+### 7.1 Python 心跳 + 登录
+
+```python
+import socket, struct, json
+
+MAGIC = 0x494D
+TYPE_SYSTEM, TYPE_ACCOUNT = 1, 2
+CMD_HEARTBEAT, CMD_LOGIN = 1, 2
+
+def pack(typ: int, cmd: int, seq: int, body: bytes = b"") -> bytes:
+    return struct.pack(">HBBII", MAGIC, typ, cmd, seq, len(body)) + body
+
+def recv_packet(sock: socket.socket):
+    header = sock.recv(12)
+    if len(header) < 12:
+        raise ConnectionError("closed")
+    magic, typ, cmd, seq, length = struct.unpack(">HBBII", header)
+    if magic != MAGIC:
+        raise ValueError("bad magic")
+    body = b""
+    while len(body) < length:
+        chunk = sock.recv(length - len(body))
+        if not chunk:
+            raise ConnectionError("closed")
+        body += chunk
+    return typ, cmd, seq, body
+
+s = socket.create_connection(("127.0.0.1", 6000))
+s.sendall(pack(TYPE_SYSTEM, CMD_HEARTBEAT, 1))
+print(recv_packet(s)[3])
+s.sendall(pack(TYPE_ACCOUNT, CMD_LOGIN, 2, json.dumps(
+    {"account": "alice", "password": "123456"}).encode()))
+print(json.loads(recv_packet(s)[3]))
+s.close()
+```
+
+### 7.2 十六进制
+
+空 body 心跳(System/Heartbeat,seq=1):
+
+```
+49 4D 01 01 00 00 00 01 00 00 00 00
+```

+ 4 - 0
server/.gitignore

@@ -0,0 +1,4 @@
+/build/
+/3rdparty/
+compile_commands.json
+*.user

+ 70 - 0
server/CMakeLists.txt

@@ -0,0 +1,70 @@
+cmake_minimum_required(VERSION 3.10)
+project(server LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+if(NOT CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR)
+    file(CREATE_LINK
+        "${CMAKE_BINARY_DIR}/compile_commands.json"
+        "${CMAKE_SOURCE_DIR}/compile_commands.json"
+        SYMBOLIC)
+endif()
+
+add_compile_definitions(HPSOCKET_STATIC_LIB YLIB_STATIC)
+
+file(GLOB_RECURSE SERVER_SOURCES CONFIGURE_DEPENDS
+    "${CMAKE_SOURCE_DIR}/src/*.cpp"
+)
+
+add_executable(${PROJECT_NAME} ${SERVER_SOURCES})
+
+set(YLIB_ROOT "/usr/local" CACHE PATH "ylib 安装前缀")
+set(HPSOCKET_ROOT "/usr/local" CACHE PATH "HPSocket 头文件前缀")
+
+target_include_directories(${PROJECT_NAME} PRIVATE
+    ${CMAKE_SOURCE_DIR}/src
+    ${CMAKE_SOURCE_DIR}/..
+    ${YLIB_ROOT}/include
+    ${HPSOCKET_ROOT}/include
+    /usr/include
+)
+
+if(MSVC)
+    target_compile_options(${PROJECT_NAME} PRIVATE /W3 /wd4819)
+    set(YLIB_LIB_DIR "${YLIB_ROOT}/lib" CACHE PATH "ylib 库目录")
+    set(HPSOCKET_LIB_DIR "${CMAKE_SOURCE_DIR}/3rdparty/HP-Socket/Lib" CACHE PATH "HPSocket 库目录")
+    target_link_directories(${PROJECT_NAME} PRIVATE ${YLIB_LIB_DIR} ${HPSOCKET_LIB_DIR})
+    target_link_libraries(${PROJECT_NAME} PRIVATE
+        $<$<CONFIG:Debug>:ylib_d>
+        $<$<CONFIG:Release>:ylib>
+        $<$<CONFIG:Debug>:HPSocket_D>
+        $<$<CONFIG:Release>:HPSocket>
+        ws2_32 iphlpapi advapi32 user32 shell32
+    )
+else()
+    target_compile_options(${PROJECT_NAME} PRIVATE -Wall -fPIC)
+    target_link_directories(${PROJECT_NAME} PRIVATE
+        /usr/local/lib
+        /usr/lib/x86_64-linux-gnu
+    )
+    if(CMAKE_BUILD_TYPE STREQUAL "Debug")
+        set(_YLIB_NAME ylib_d)
+    else()
+        set(_YLIB_NAME ylib)
+    endif()
+    target_link_libraries(${PROJECT_NAME} PRIVATE
+        ${_YLIB_NAME}
+        hpsocket
+        mysqlcppconn
+        curl
+        ssl
+        crypto
+        pthread
+        dl
+        rt
+        z
+    )
+endif()

+ 22 - 0
server/README.md

@@ -0,0 +1,22 @@
+# Server
+
+C++ IM TCP server with MySQL. Protocol: ../docs/protocol.md
+
+Chat images/files: PutFile returns a temporary COS token; the client uploads bytes to COS directly.
+
+Layout:
+  src/app          startup and command registration
+  src/im/handlers  heartbeat, auth, chat, contacts
+  src/db           pool and SQL
+  src/net          framing, sessions, kick
+  sql              init.sql (new) / migrate.sql (upgrade)
+  config/config.ini
+
+Build:
+  cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
+  cmake --build build
+  ./build/server --init-db config/config.ini   # 建库建表后启动
+  ./build/server config/config.ini             # 平时启动
+
+Auth, friends, conversations and chat require MySQL.
+Set [mysql] required=1 so startup fails if the database is down.

+ 117 - 0
server/config/config.ini.example

@@ -0,0 +1,117 @@
+[server]
+# 监听地址,0.0.0.0 表示所有网卡
+host=0.0.0.0
+# 监听端口
+port=6000
+# 最大同时连接数
+max_connections=10000
+
+[protocol]
+# 包头魔数,十六进制(ASCII "IM"),须与客户端一致
+magic=0x494D
+# 单包 body 最大字节。聊天图片/文件走 COS 直传,不再占用 TCP 包体。
+max_packet=1048576
+
+[limits]
+# 账号最短长度(字母或数字)
+account_min=4
+# 账号最长长度
+account_max=16
+# 密码最短长度
+password_min=6
+# 密码最长长度
+password_max=20
+# 手机号位数
+phone_len=11
+# 昵称最长字节
+nickname_max=32
+# 签名最长字节
+signature_max=64
+# 单条消息 content 最长字节
+content_max=4096
+# 会话列表预览最长字节
+preview_max=120
+# 历史消息默认条数
+history_default=50
+# 历史消息单次拉取上限
+history_max=100
+# 头像最大字节(JPEG)
+avatar_max=81920
+# 聊天图片/文件最大字节(客户端直传 COS,PutFile 只校验 size)
+file_max=104857600
+
+[cos]
+# 1=头像、聊天图片/文件、表情全部走腾讯云 COS。聊天图片/文件由客户端持临时 token 直传,不经 IM 服务端。secret 未填齐则启动失败
+enable=1
+# 腾讯云 API 密钥 ID
+secret_id=
+# 腾讯云 API 密钥 Key
+secret_key=
+# 地域,如 ap-guangzhou / ap-beijing / ap-hongkong
+region=ap-beijing
+# 存储桶,格式 bucket-appid,如 examplebucket-1250000000
+bucket=
+# 对象键前缀,头像 {prefix}/avatars/{uid}.jpg,文件 {prefix}/files/{file_id}.{ext},表情 {prefix}/stickers/...
+prefix=im
+
+[cdn]
+# 资源公网域名(无尾斜杠),SetLang 原样下发给客户端
+# 客户端用 domain + "/" + url 拼 HTTP 下载地址
+# 必须填写;留空则下发空域名,客户端不走 HTTP 下载
+domain=
+
+[update]
+# 客户端最新版本号,与客户端 [app] version 比较,更高则提示更新
+version=1.0.0
+# 最新安装包 ZIP 下载地址;留空则不下发更新
+url=
+# 更新说明,展示在提示框
+notes=
+
+[mysql]
+# 1=启用 MySQL;0=不连库(多数业务不可用)
+enable=1
+# 1=启动失败则退出进程;0=仅告警,TCP 仍启动
+required=1
+# 数据库主机
+host=127.0.0.1
+# 数据库端口
+port=3306
+# 数据库用户
+user=
+# 数据库密码
+password=
+# 库名
+database=im
+# 连接字符集
+charset=utf8mb4
+# 连接池大小
+pool_size=50
+
+[sms]
+# 1=不发短信,验证码打到控制台
+debug=0
+# 1=响应带回验证码,便于客户端联调;生产环境务必改为 0
+echo_code=0
+# 验证码有效期(秒)
+ttl_sec=300
+# 同一手机号+场景最短发送间隔(秒)
+interval_sec=60
+# 验证码位数
+code_len=6
+# 填了 appid 才向赛邮发短信;留空则只落库(配合 echo_code 本地联调)
+appid=
+# 赛邮短信 appkey
+appkey=
+# 国内短信签名,对应接口 sms_signature
+signature=
+# 赛邮模板 ID
+template_code=
+# 0=国内 sms/xsend,1=国际 internationalsms/xsend
+international=0
+
+[log]
+# 0=关闭全部日志;1=按 level 过滤后打到 stdout
+enable=1
+# 日志等级:debug / info / warn / error
+level=info

+ 65 - 0
server/init.sh

@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# 首次环境:装编译依赖、cmake 编译、建库建表并启动
+# 用法: ./init.sh [config.ini]
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$ROOT"
+
+CONFIG="${1:-config/config.ini}"
+BUILD_TYPE="${BUILD_TYPE:-Release}"
+JOBS="$(nproc 2>/dev/null || echo 4)"
+
+if [[ ! -f "$CONFIG" ]]; then
+    echo "config not found: $CONFIG" >&2
+    exit 1
+fi
+
+APT_PACKAGES=(
+    build-essential
+    cmake
+    libcurl4-openssl-dev
+    libssl-dev
+    libmysqlcppconn-dev
+    zlib1g-dev
+)
+
+missing=()
+for pkg in "${APT_PACKAGES[@]}"; do
+    if ! dpkg -s "$pkg" >/dev/null 2>&1; then
+        missing+=("$pkg")
+    fi
+done
+if (( ${#missing[@]} > 0 )); then
+    echo "install packages: ${missing[*]}"
+    if [[ "$(id -u)" -eq 0 ]]; then
+        apt-get update
+        apt-get install -y "${missing[@]}"
+    else
+        sudo apt-get update
+        sudo apt-get install -y "${missing[@]}"
+    fi
+fi
+
+if [[ ! -e /usr/local/include/util/json.h && ! -e /usr/local/include/ylib/util/json.h ]]; then
+    echo "ylib headers not found under /usr/local/include" >&2
+    exit 1
+fi
+if [[ ! -e /usr/local/lib/libylib.a && ! -e /usr/local/lib/libylib.so && ! -e /usr/local/lib/libylib_d.a ]]; then
+    echo "ylib library not found under /usr/local/lib" >&2
+    exit 1
+fi
+if [[ ! -e /usr/local/lib/libhpsocket.a && ! -e /usr/local/lib/libhpsocket.so ]]; then
+    echo "hpsocket library not found under /usr/local/lib" >&2
+    exit 1
+fi
+
+cmake -S . -B build -DCMAKE_BUILD_TYPE="$BUILD_TYPE"
+cmake --build build -j"$JOBS"
+
+if [[ ! -x ./build/server ]]; then
+    echo "build/server not found" >&2
+    exit 1
+fi
+
+exec ./build/server --init-db "$CONFIG"

+ 124 - 0
server/sql/init.sql

@@ -0,0 +1,124 @@
+CREATE DATABASE IF NOT EXISTS im DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+USE im;
+
+CREATE TABLE IF NOT EXISTS im_user (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    username    VARCHAR(64)     NOT NULL,
+    password    VARCHAR(128)    NOT NULL,
+    nickname    VARCHAR(64)     NOT NULL DEFAULT '',
+    phone       VARCHAR(20)     NOT NULL DEFAULT '',
+    signature   VARCHAR(128)    NOT NULL DEFAULT '',
+    allow_search_account TINYINT NOT NULL DEFAULT 1 COMMENT '1允许通过账号搜索',
+    allow_search_phone   TINYINT NOT NULL DEFAULT 1 COMMENT '1允许通过手机号搜索',
+    allow_add_friend     TINYINT NOT NULL DEFAULT 1 COMMENT '1允许他人添加好友',
+    avatar_ver  INT             NOT NULL DEFAULT 0,
+    status      TINYINT         NOT NULL DEFAULT 1 COMMENT '1正常 0禁用 2系统账号',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_username (username),
+    KEY idx_phone (phone)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_sms_code (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    phone       VARCHAR(20)     NOT NULL,
+    scene       VARCHAR(16)     NOT NULL,
+    code        VARCHAR(8)      NOT NULL,
+    expire_at   DATETIME        NOT NULL,
+    used        TINYINT         NOT NULL DEFAULT 0,
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_phone_scene (phone, scene, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_friend (
+    uid         BIGINT UNSIGNED NOT NULL,
+    friend_uid  BIGINT UNSIGNED NOT NULL,
+    remark      VARCHAR(64)     NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (uid, friend_uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_message (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    to_uid      BIGINT UNSIGNED NOT NULL,
+    msg_type    TINYINT         NOT NULL DEFAULT 1,
+    content     TEXT            NOT NULL,
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_to_uid (to_uid, id),
+    KEY idx_from_uid (from_uid, id),
+    KEY idx_pair (from_uid, to_uid, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_conversation (
+    uid           BIGINT UNSIGNED NOT NULL,
+    peer_uid      BIGINT UNSIGNED NOT NULL,
+    last_msg_id   BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    last_content  VARCHAR(256)    NOT NULL DEFAULT '',
+    last_time     DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    unread        INT             NOT NULL DEFAULT 0,
+    PRIMARY KEY (uid, peer_uid),
+    KEY idx_uid_time (uid, last_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 系统账号:文件传输助手。密码不可登录(status=2)
+CREATE TABLE IF NOT EXISTS im_friend_request (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    to_uid      BIGINT UNSIGNED NOT NULL,
+    status      TINYINT         NOT NULL DEFAULT 0 COMMENT '0待处理 1已同意 2已拒绝',
+    message     VARCHAR(64)     NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_from_to (from_uid, to_uid),
+    KEY idx_to_status (to_uid, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_sticker_pack (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    name        VARCHAR(64)     NOT NULL,
+    version     INT             NOT NULL DEFAULT 1,
+    sort_order  INT             NOT NULL DEFAULT 0,
+    status      TINYINT         NOT NULL DEFAULT 1,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_sticker (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    pack_id     BIGINT UNSIGNED NOT NULL,
+    code        VARCHAR(64)     NOT NULL,
+    name        VARCHAR(64)     NOT NULL DEFAULT '',
+    file_path   VARCHAR(255)    NOT NULL,
+    sha256      VARCHAR(64)     NOT NULL DEFAULT '',
+    mime        VARCHAR(32)     NOT NULL DEFAULT 'image/png',
+    sort_order  INT             NOT NULL DEFAULT 0,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_pack_code (pack_id, code),
+    KEY idx_pack (pack_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_file (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    name        VARCHAR(255)    NOT NULL,
+    mime        VARCHAR(64)     NOT NULL DEFAULT 'application/octet-stream',
+    size        INT             NOT NULL DEFAULT 0,
+    sha256      VARCHAR(64)     NOT NULL DEFAULT '',
+    file_path   VARCHAR(255)    NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_from (from_uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 系统账号:文件传输助手。密码不可登录(status=2)
+INSERT INTO im_user (id, username, password, nickname, phone, signature, status)
+VALUES (1, 'filehelper', '-', '文件传输助手', '', '文件助手', 2)
+ON DUPLICATE KEY UPDATE nickname=VALUES(nickname), signature=VALUES(signature), status=VALUES(status);
+
+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);

+ 111 - 0
server/sql/migrate.sql

@@ -0,0 +1,111 @@
+-- Upgrade script for existing databases. Ignore errors if a column already exists.
+USE im;
+
+ALTER TABLE im_user
+    ADD COLUMN phone VARCHAR(20) NOT NULL DEFAULT '' AFTER nickname;
+ALTER TABLE im_user
+    ADD COLUMN signature VARCHAR(128) NOT NULL DEFAULT '' AFTER phone;
+ALTER TABLE im_user
+    ADD KEY idx_phone (phone);
+
+CREATE TABLE IF NOT EXISTS im_sms_code (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    phone       VARCHAR(20)     NOT NULL,
+    scene       VARCHAR(16)     NOT NULL,
+    code        VARCHAR(8)      NOT NULL,
+    expire_at   DATETIME        NOT NULL,
+    used        TINYINT         NOT NULL DEFAULT 0,
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_phone_scene (phone, scene, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_friend (
+    uid         BIGINT UNSIGNED NOT NULL,
+    friend_uid  BIGINT UNSIGNED NOT NULL,
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (uid, friend_uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_conversation (
+    uid           BIGINT UNSIGNED NOT NULL,
+    peer_uid      BIGINT UNSIGNED NOT NULL,
+    last_msg_id   BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    last_content  VARCHAR(256)    NOT NULL DEFAULT '',
+    last_time     DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    unread        INT             NOT NULL DEFAULT 0,
+    PRIMARY KEY (uid, peer_uid),
+    KEY idx_uid_time (uid, last_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO im_user (id, username, password, nickname, phone, signature, status)
+VALUES (1, 'filehelper', '-', 'filehelper', '', 'files', 2)
+ON DUPLICATE KEY UPDATE nickname=VALUES(nickname), signature=VALUES(signature), status=VALUES(status);
+
+ALTER TABLE im_user
+    ADD COLUMN allow_search_account TINYINT NOT NULL DEFAULT 1 AFTER signature;
+ALTER TABLE im_user
+    ADD COLUMN allow_search_phone TINYINT NOT NULL DEFAULT 1 AFTER allow_search_account;
+ALTER TABLE im_user
+    ADD COLUMN allow_add_friend TINYINT NOT NULL DEFAULT 1 AFTER allow_search_phone;
+
+CREATE TABLE IF NOT EXISTS im_friend_request (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    to_uid      BIGINT UNSIGNED NOT NULL,
+    status      TINYINT         NOT NULL DEFAULT 0 COMMENT '0待处理 1已同意 2已拒绝',
+    message     VARCHAR(64)     NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_from_to (from_uid, to_uid),
+    KEY idx_to_status (to_uid, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+ALTER TABLE im_friend_request
+    ADD COLUMN message VARCHAR(64) NOT NULL DEFAULT '' AFTER status;
+
+ALTER TABLE im_friend
+    ADD COLUMN remark VARCHAR(64) NOT NULL DEFAULT '' AFTER friend_uid;
+
+CREATE TABLE IF NOT EXISTS im_sticker_pack (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    name        VARCHAR(64)     NOT NULL,
+    version     INT             NOT NULL DEFAULT 1,
+    sort_order  INT             NOT NULL DEFAULT 0,
+    status      TINYINT         NOT NULL DEFAULT 1,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_sticker (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    pack_id     BIGINT UNSIGNED NOT NULL,
+    code        VARCHAR(64)     NOT NULL,
+    name        VARCHAR(64)     NOT NULL DEFAULT '',
+    file_path   VARCHAR(255)    NOT NULL,
+    sha256      VARCHAR(64)     NOT NULL DEFAULT '',
+    mime        VARCHAR(32)     NOT NULL DEFAULT 'image/png',
+    sort_order  INT             NOT NULL DEFAULT 0,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_pack_code (pack_id, code),
+    KEY idx_pack (pack_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+ALTER TABLE im_user ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0;
+
+CREATE TABLE IF NOT EXISTS im_file (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    name        VARCHAR(255)    NOT NULL,
+    mime        VARCHAR(64)     NOT NULL DEFAULT 'application/octet-stream',
+    size        INT             NOT NULL DEFAULT 0,
+    sha256      VARCHAR(64)     NOT NULL DEFAULT '',
+    file_path   VARCHAR(255)    NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_from (from_uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+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);

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

@@ -0,0 +1,168 @@
+#include "app/application.h"
+
+#include <atomic>
+#include <chrono>
+#include <csignal>
+#include <memory>
+#include <string>
+#include <thread>
+
+#include "common/logger.h"
+#include "third/cos.h"
+#include "util/file.h"
+#include "im/handlers/account_handler.h"
+#include "im/handlers/friend_handler.h"
+#include "im/handlers/message_handler.h"
+#include "im/handlers/sticker_handler.h"
+#include "im/handlers/system_handler.h"
+#include "db/sticker_store.h"
+
+namespace im {
+namespace {
+
+std::atomic<bool> g_running{true};
+
+void on_signal(int)
+{
+    g_running = false;
+}
+
+std::string find_init_sql(const std::string& cfg_path)
+{
+    const std::string parent = ylib::file::parent_dir(cfg_path);
+    const std::string candidates[] = {
+        parent + "/../sql/init.sql",
+        parent + "/sql/init.sql",
+        "sql/init.sql",
+        "../sql/init.sql",
+    };
+    for (const auto& path : candidates) {
+        if (ylib::file::exist(path)) {
+            return path;
+        }
+    }
+    return parent + "/../sql/init.sql";
+}
+
+}  // namespace
+
+int Application::run(int argc, char** argv)
+{
+    if (!init(argc, argv)) {
+        return 1;
+    }
+    wait_shutdown();
+    shutdown();
+    return 0;
+}
+
+bool Application::init(int argc, char** argv)
+{
+    bool init_db = false;
+    std::string cfg_path;
+    for (int i = 1; i < argc; ++i) {
+        const std::string arg = argv[i] != nullptr ? argv[i] : "";
+        if (arg.empty()) {
+            continue;
+        }
+        if (arg == "--init-db") {
+            init_db = true;
+        } else if (arg[0] == '-') {
+            Logger::error("unknown option: " + arg);
+            Logger::error("usage: server [--init-db] [config.ini]");
+            return false;
+        } else {
+            cfg_path = arg;
+        }
+    }
+    if (cfg_path.empty()) {
+        cfg_path = AppConfig::resolve_path(0, nullptr);
+    }
+    if (!config_.load(cfg_path)) {
+        return false;
+    }
+
+    if (init_db) {
+        if (!config_.mysql().enable) {
+            Logger::error("--init-db requires [mysql] enable=1");
+            return false;
+        }
+        const std::string sql_path = find_init_sql(cfg_path);
+        if (!ylib::file::exist(sql_path)) {
+            Logger::error("init.sql not found, tried near " + cfg_path);
+            return false;
+        }
+        Logger::info("init database from " + sql_path);
+        if (!mysql_.init_schema(config_.mysql(), sql_path)) {
+            return false;
+        }
+    }
+
+    if (config_.cos().enable) {
+        if (third::cos::enabled(config_.cos())) {
+            Logger::info("cos storage bucket=" + config_.cos().bucket +
+                         " region=" + config_.cos().region +
+                         " prefix=" + config_.cos().prefix);
+        } else {
+            Logger::error("cos enable=1 but secret_id/secret_key/bucket/region incomplete");
+            return false;
+        }
+    } else {
+        Logger::error("COS storage is required");
+        return false;
+    }
+    if (!config_.cdn_domain().empty()) {
+        Logger::info("cdn domain=" + config_.cdn_domain());
+    }
+
+    if (config_.mysql().enable) {
+        if (!mysql_.start(config_.mysql())) {
+            if (config_.mysql().required) {
+                Logger::error("mysql is required, abort");
+                return false;
+            }
+            Logger::warn("mysql unavailable, continue without db");
+        }
+    }
+
+    dispatcher_.add(Type::System, [] { return std::make_unique<SystemHandler>(); });
+    dispatcher_.add(Type::Account, [] { return std::make_unique<AccountHandler>(); });
+    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>(); });
+
+    if (mysql_.started()) {
+        StickerStore stickers(mysql_);
+        stickers.seed();
+    }
+
+    server_ = std::make_unique<ImServer>(config_, sessions_, dispatcher_, mysql_);
+    if (!server_->start()) {
+        return false;
+    }
+
+    Logger::info("im server ready, packet=magic:type:cmd:seq:len + body");
+    return true;
+}
+
+void Application::wait_shutdown()
+{
+    std::signal(SIGINT, on_signal);
+    std::signal(SIGTERM, on_signal);
+    Logger::info("press Ctrl+C to stop");
+    while (g_running) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(200));
+    }
+}
+
+void Application::shutdown()
+{
+    Logger::info("shutting down");
+    if (server_) {
+        server_->stop();
+        server_.reset();
+    }
+    mysql_.stop();
+}
+
+}  // namespace im

+ 29 - 0
server/src/app/application.h

@@ -0,0 +1,29 @@
+#pragma once
+
+#include <memory>
+
+#include "config/app_config.h"
+#include "db/mysql_service.h"
+#include "im/dispatcher.h"
+#include "net/im_server.h"
+#include "net/session_mgr.h"
+
+namespace im {
+
+class Application {
+public:
+    int run(int argc, char** argv);
+
+private:
+    bool init(int argc, char** argv);
+    void wait_shutdown();
+    void shutdown();
+
+    AppConfig config_;
+    MysqlService mysql_;
+    SessionMgr sessions_;
+    Dispatcher dispatcher_;
+    std::unique_ptr<ImServer> server_;
+};
+
+}  // namespace im

+ 36 - 0
server/src/common/byte_order.h

@@ -0,0 +1,36 @@
+#pragma once
+
+#include <cstdint>
+#include <cstring>
+
+namespace im {
+
+inline uint16_t read_u16_be(const char* p)
+{
+    return static_cast<uint16_t>(
+        (static_cast<uint8_t>(p[0]) << 8) | static_cast<uint8_t>(p[1]));
+}
+
+inline uint32_t read_u32_be(const char* p)
+{
+    return (static_cast<uint32_t>(static_cast<uint8_t>(p[0])) << 24) |
+           (static_cast<uint32_t>(static_cast<uint8_t>(p[1])) << 16) |
+           (static_cast<uint32_t>(static_cast<uint8_t>(p[2])) << 8) |
+           (static_cast<uint32_t>(static_cast<uint8_t>(p[3])));
+}
+
+inline void write_u16_be(char* p, uint16_t v)
+{
+    p[0] = static_cast<char>((v >> 8) & 0xFF);
+    p[1] = static_cast<char>(v & 0xFF);
+}
+
+inline void write_u32_be(char* p, uint32_t v)
+{
+    p[0] = static_cast<char>((v >> 24) & 0xFF);
+    p[1] = static_cast<char>((v >> 16) & 0xFF);
+    p[2] = static_cast<char>((v >> 8) & 0xFF);
+    p[3] = static_cast<char>(v & 0xFF);
+}
+
+}  // namespace im

+ 124 - 0
server/src/common/crypto.cpp

@@ -0,0 +1,124 @@
+#include "common/crypto.h"
+
+#include <iomanip>
+#include <random>
+#include <sstream>
+
+#include <openssl/sha.h>
+
+namespace im {
+
+std::string sha256_hex(const std::string& input)
+{
+    unsigned char hash[SHA256_DIGEST_LENGTH] = {0};
+    SHA256(reinterpret_cast<const unsigned char*>(input.data()), input.size(), hash);
+    std::ostringstream oss;
+    oss << std::hex << std::setfill('0');
+    for (int i = 0; i < SHA256_DIGEST_LENGTH; ++i) {
+        oss << std::setw(2) << static_cast<int>(hash[i]);
+    }
+    return oss.str();
+}
+
+std::string base64_encode(const void* data, size_t len)
+{
+    static const char kTbl[] =
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+    const auto* p = static_cast<const unsigned char*>(data);
+    std::string out;
+    out.reserve(((len + 2) / 3) * 4);
+    size_t i = 0;
+    while (i + 2 < len) {
+        const unsigned int n = (p[i] << 16) | (p[i + 1] << 8) | p[i + 2];
+        out.push_back(kTbl[(n >> 18) & 63]);
+        out.push_back(kTbl[(n >> 12) & 63]);
+        out.push_back(kTbl[(n >> 6) & 63]);
+        out.push_back(kTbl[n & 63]);
+        i += 3;
+    }
+    if (i < len) {
+        unsigned int n = p[i] << 16;
+        if (i + 1 < len) {
+            n |= p[i + 1] << 8;
+        }
+        out.push_back(kTbl[(n >> 18) & 63]);
+        out.push_back(kTbl[(n >> 12) & 63]);
+        out.push_back(i + 1 < len ? kTbl[(n >> 6) & 63] : '=');
+        out.push_back('=');
+    }
+    return out;
+}
+
+bool base64_decode(const std::string& input, std::string& output)
+{
+    static const int kRev[256] = {
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63,
+        52,53,54,55,56,57,58,59,60,61,-1,-1,-1,-1,-1,-1,
+        -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14,
+        15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1,
+        -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,
+        41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
+        -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1};
+    output.clear();
+    int val = 0;
+    int bits = -8;
+    for (unsigned char c : input) {
+        if (c == '=' || c == '\n' || c == '\r' || c == ' ' || c == '\t') {
+            if (c == '=') {
+                break;
+            }
+            continue;
+        }
+        const int d = kRev[c];
+        if (d < 0) {
+            output.clear();
+            return false;
+        }
+        val = (val << 6) + d;
+        bits += 6;
+        if (bits >= 0) {
+            output.push_back(static_cast<char>((val >> bits) & 0xFF));
+            bits -= 8;
+        }
+    }
+    return true;
+}
+
+std::string random_digits(int n)
+{
+    if (n <= 0) {
+        return {};
+    }
+    std::random_device rd;
+    std::mt19937 gen(rd());
+    std::uniform_int_distribution<int> dist(0, 9);
+    std::string out;
+    out.reserve(static_cast<size_t>(n));
+    for (int i = 0; i < n; ++i) {
+        out.push_back(static_cast<char>('0' + dist(gen)));
+    }
+    return out;
+}
+
+std::string preview_text(const std::string& text, size_t max_bytes)
+{
+    if (text.size() <= max_bytes) {
+        return text;
+    }
+    size_t n = max_bytes;
+    while (n > 0 && (static_cast<unsigned char>(text[n]) & 0xC0) == 0x80) {
+        --n;
+    }
+    return text.substr(0, n);
+}
+
+}  // namespace im

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff