mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
35 Commits
@@ -6,11 +6,12 @@
|
||||
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
|
||||
<p>微信4.x数据解密并生成年度总结,高仿微信,支持实时更新,导出聊天记录,朋友圈等大量便捷功能</p>
|
||||
<p><b>特别致谢</b>:<a href="https://github.com/H3CoF6">H3CoF6</a>(密钥与朋友圈等核心内容的技术支持)、<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现)</p>
|
||||
<p>如需定制功能,请联系 QQ:2977094657。</p>
|
||||
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
|
||||
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
|
||||
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
|
||||
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ%20Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
|
||||
<img src="https://img.shields.io/badge/Vue.js-4FC08D?logo=Vue.js&logoColor=white" alt="Vue.js" />
|
||||
<img src="https://img.shields.io/badge/SQLite-003B57?logo=SQLite&logoColor=white" alt="SQLite" />
|
||||
@@ -192,16 +193,6 @@ npm run dist
|
||||
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
|
||||
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
|
||||
|
||||
## 修改消息
|
||||
|
||||
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
|
||||
|
||||
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
|
||||
</p>
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
; This file is included for both installer and uninstaller builds.
|
||||
; Guard installer-only pages/functions to avoid "function not referenced" warnings
|
||||
; when electron-builder compiles the standalone uninstaller.
|
||||
!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json"
|
||||
!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output"
|
||||
!ifdef APP_PRODUCT_FILENAME
|
||||
!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json"
|
||||
!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output"
|
||||
!else
|
||||
!define /ifndef WDA_PRODUCT_SETTINGS_PATH ""
|
||||
!define /ifndef WDA_PRODUCT_OUTPUT_DIR ""
|
||||
!endif
|
||||
!ifdef APP_PACKAGE_NAME
|
||||
!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json"
|
||||
!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output"
|
||||
!else
|
||||
!define /ifndef WDA_PACKAGE_SETTINGS_PATH ""
|
||||
!define /ifndef WDA_PACKAGE_OUTPUT_DIR ""
|
||||
!endif
|
||||
!ifndef BUILD_UNINSTALLER
|
||||
!include nsDialogs.nsh
|
||||
!include LogicLib.nsh
|
||||
@@ -13,6 +29,10 @@
|
||||
!define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:"
|
||||
|
||||
Var WDA_InstallDirPage
|
||||
Var WDA_OutputDirPage
|
||||
Var WDA_OutputDirInput
|
||||
Var WDA_OutputDirBrowseButton
|
||||
Var WDA_SelectedOutputDir
|
||||
|
||||
!macro customInit
|
||||
; Safety: older versions created an `output` junction inside the install directory that points to the
|
||||
@@ -22,17 +42,10 @@ Var WDA_InstallDirPage
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Provide a safe, non-junction way for users to locate the real per-user output directory.
|
||||
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
|
||||
; `open-output.cmd` uses %APPDATA% so it works for the current user.
|
||||
FileOpen $0 "$INSTDIR\output-location.txt" w
|
||||
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
|
||||
FileClose $0
|
||||
|
||||
FileOpen $1 "$INSTDIR\open-output.cmd" w
|
||||
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
|
||||
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
|
||||
FileClose $1
|
||||
${If} $WDA_SelectedOutputDir == ""
|
||||
Call WDA_InitOutputDirSelection
|
||||
${EndIf}
|
||||
Call WDA_WritePendingOutputDirSetting
|
||||
!macroend
|
||||
|
||||
Function WDA_RemoveLegacyOutputLink
|
||||
@@ -47,9 +60,27 @@ FunctionEnd
|
||||
; the final install location (includes the app sub-folder).
|
||||
!ifdef allowToChangeInstallationDirectory
|
||||
Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave
|
||||
Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave
|
||||
!endif
|
||||
!macroend
|
||||
|
||||
Function WDA_InitOutputDirSelection
|
||||
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 == "0"
|
||||
${AndIf} $1 != ""
|
||||
StrCpy $WDA_SelectedOutputDir "$1"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_WritePendingOutputDirSetting
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_EnsureAppSubDir
|
||||
; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder).
|
||||
StrCpy $0 "$INSTDIR"
|
||||
@@ -105,6 +136,48 @@ FunctionEnd
|
||||
Function WDA_InstallDirPageLeave
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_OutputDirBrowse
|
||||
nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir"
|
||||
Pop $0
|
||||
${If} $0 != error
|
||||
StrCpy $WDA_SelectedOutputDir "$0"
|
||||
${NSD_SetText} $WDA_OutputDirInput "$0"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_OutputDirPageCreate
|
||||
Call WDA_InitOutputDirSelection
|
||||
|
||||
nsDialogs::Create 1018
|
||||
Pop $WDA_OutputDirPage
|
||||
|
||||
${If} $WDA_OutputDirPage == error
|
||||
Abort
|
||||
${EndIf}
|
||||
|
||||
${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。"
|
||||
Pop $0
|
||||
|
||||
${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir"
|
||||
Pop $WDA_OutputDirInput
|
||||
|
||||
${NSD_CreateButton} 82% 27u 18% 14u "浏览..."
|
||||
Pop $WDA_OutputDirBrowseButton
|
||||
${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse
|
||||
|
||||
${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。"
|
||||
Pop $0
|
||||
|
||||
nsDialogs::Show
|
||||
FunctionEnd
|
||||
|
||||
Function WDA_OutputDirPageLeave
|
||||
${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir
|
||||
${If} $WDA_SelectedOutputDir == ""
|
||||
StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}"
|
||||
${EndIf}
|
||||
FunctionEnd
|
||||
|
||||
!endif
|
||||
|
||||
!ifdef BUILD_UNINSTALLER
|
||||
@@ -177,6 +250,12 @@ FunctionEnd
|
||||
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
|
||||
!endif
|
||||
|
||||
IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete
|
||||
nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
WDA_SkipCustomOutputDelete:
|
||||
|
||||
${if} $installMode == "all"
|
||||
SetShellVarContext all
|
||||
${endif}
|
||||
|
||||
+481
-23
@@ -21,6 +21,13 @@ const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const {
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
} = require("./output-dir.cjs");
|
||||
|
||||
const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
||||
const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392;
|
||||
@@ -32,6 +39,7 @@ let tray = null;
|
||||
let isQuitting = false;
|
||||
let desktopSettings = null;
|
||||
let backendPortChangeInProgress = false;
|
||||
let outputDirChangeInProgress = false;
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
@@ -216,9 +224,76 @@ function resolveDataDir() {
|
||||
}
|
||||
|
||||
function getUserDataDir() {
|
||||
// Backwards-compat: we historically used Electron's userData directory for runtime storage.
|
||||
// Keep this name but resolve to the effective data dir (can be overridden via env).
|
||||
return resolveDataDir();
|
||||
try {
|
||||
const dir = app.getPath("userData");
|
||||
if (!dir) return null;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function safeNormalizeDirectory(value) {
|
||||
try {
|
||||
return normalizeDirectoryPath(value || "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultOutputDir() {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) return null;
|
||||
try {
|
||||
return getDefaultOutputDirPath(dataDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncOutputDirEnv(nextDir) {
|
||||
const normalized = safeNormalizeDirectory(nextDir);
|
||||
if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized;
|
||||
else delete process.env.WECHAT_TOOL_OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function normalizePendingOutputDirValue(value) {
|
||||
if (value == null) return null;
|
||||
const text = String(value).trim();
|
||||
if (!text) return "";
|
||||
try {
|
||||
return normalizeDirectoryPath(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOutputDir() {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) return null;
|
||||
|
||||
const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || "");
|
||||
const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : "";
|
||||
|
||||
let chosen = null;
|
||||
try {
|
||||
chosen = getEffectiveOutputDirPath({
|
||||
dataDir,
|
||||
envOutputDir,
|
||||
settingsOutputDir,
|
||||
});
|
||||
} catch {
|
||||
chosen = getDefaultOutputDir();
|
||||
}
|
||||
if (!chosen) return null;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(chosen, { recursive: true });
|
||||
} catch {}
|
||||
|
||||
syncOutputDirEnv(chosen);
|
||||
return chosen;
|
||||
}
|
||||
|
||||
function sanitizeAccountName(account) {
|
||||
@@ -261,7 +336,8 @@ function resolveAccountDirInOutput(account) {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) throw new Error("无法定位数据目录");
|
||||
|
||||
const outputDir = path.join(dataDir, "output");
|
||||
const outputDir = resolveOutputDir();
|
||||
if (!outputDir) throw new Error("无法定位 output 目录");
|
||||
const databasesDir = path.join(outputDir, "databases");
|
||||
const accountName = sanitizeAccountName(account);
|
||||
|
||||
@@ -311,8 +387,8 @@ function getAccountInfoFromDisk(account) {
|
||||
};
|
||||
}
|
||||
|
||||
function removeAccountFromKeyStore(dataDir, accountName) {
|
||||
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
|
||||
function removeAccountFromKeyStore(outputDir, accountName) {
|
||||
const keyStorePath = path.join(outputDir, "account_keys.json");
|
||||
try {
|
||||
if (!fs.existsSync(keyStorePath)) return false;
|
||||
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
|
||||
@@ -328,7 +404,7 @@ function removeAccountFromKeyStore(dataDir, accountName) {
|
||||
}
|
||||
|
||||
async function deleteAccountDataFromDisk(account) {
|
||||
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
|
||||
const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
|
||||
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
|
||||
throw new Error("账号数据不存在");
|
||||
}
|
||||
@@ -348,7 +424,7 @@ async function deleteAccountDataFromDisk(account) {
|
||||
} catch {}
|
||||
|
||||
fs.rmSync(accountDir, { recursive: true, force: true });
|
||||
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
|
||||
const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName);
|
||||
const accounts = listDecryptedAccountsOnDisk(databasesDir);
|
||||
result = {
|
||||
status: "success",
|
||||
@@ -394,10 +470,8 @@ function ensureOutputLink() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
const exeDir = getExeDir();
|
||||
const dataDir = resolveDataDir();
|
||||
if (!exeDir || !dataDir) return;
|
||||
|
||||
const target = path.join(dataDir, "output");
|
||||
const target = resolveOutputDir();
|
||||
if (!exeDir || !target) return;
|
||||
const legacyLinkPath = path.join(exeDir, "output");
|
||||
|
||||
// Ensure the real output dir exists.
|
||||
@@ -443,6 +517,11 @@ function ensureOutputLink() {
|
||||
fs.writeFileSync(p, text, { encoding: "utf8" });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const p = path.join(exeDir, "output-location.path");
|
||||
fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const p = path.join(exeDir, "open-output.cmd");
|
||||
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
|
||||
@@ -510,6 +589,12 @@ function loadDesktopSettings() {
|
||||
ignoredUpdateVersion: "",
|
||||
// Backend (FastAPI) listens on this port. Used in packaged builds.
|
||||
backendPort: DEFAULT_BACKEND_PORT,
|
||||
// Custom output dir; empty string means use the default dataDir/output.
|
||||
outputDir: "",
|
||||
// Pending output dir written by the installer before the next app startup.
|
||||
pendingOutputDir: null,
|
||||
// Last startup/apply failure when changing output dir.
|
||||
lastOutputDirError: "",
|
||||
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
|
||||
// after upgrades without wiping user data/localStorage.
|
||||
lastSeenUiBuildId: "",
|
||||
@@ -530,6 +615,12 @@ function loadDesktopSettings() {
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
|
||||
desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort;
|
||||
desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || "");
|
||||
desktopSettings.pendingOutputDir =
|
||||
parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir")
|
||||
? normalizePendingOutputDirValue(parsed.pendingOutputDir)
|
||||
: defaults.pendingOutputDir;
|
||||
desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim();
|
||||
} catch (err) {
|
||||
desktopSettings = { ...defaults };
|
||||
logMain(`[main] failed to load settings: ${err?.message || err}`);
|
||||
@@ -551,6 +642,82 @@ function persistDesktopSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotOutputDirSettings() {
|
||||
loadDesktopSettings();
|
||||
return {
|
||||
outputDir: desktopSettings.outputDir,
|
||||
pendingOutputDir: desktopSettings.pendingOutputDir,
|
||||
lastOutputDirError: desktopSettings.lastOutputDirError,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreOutputDirSettings(snapshot) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || "");
|
||||
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir);
|
||||
desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim();
|
||||
const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || "";
|
||||
syncOutputDirEnv(effectiveOutputDir);
|
||||
persistDesktopSettings();
|
||||
}
|
||||
|
||||
function setOutputDirSetting(nextDir) {
|
||||
loadDesktopSettings();
|
||||
const defaultDir = getDefaultOutputDir();
|
||||
const normalized = safeNormalizeDirectory(nextDir || "");
|
||||
if (!normalized || (defaultDir && normalized === defaultDir)) {
|
||||
desktopSettings.outputDir = "";
|
||||
} else {
|
||||
desktopSettings.outputDir = normalized;
|
||||
}
|
||||
syncOutputDirEnv(desktopSettings.outputDir || defaultDir || "");
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.outputDir;
|
||||
}
|
||||
|
||||
function setPendingOutputDirSetting(nextDir) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir);
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.pendingOutputDir;
|
||||
}
|
||||
|
||||
function clearPendingOutputDirSetting() {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.pendingOutputDir = null;
|
||||
persistDesktopSettings();
|
||||
}
|
||||
|
||||
function setOutputDirLastError(message) {
|
||||
loadDesktopSettings();
|
||||
desktopSettings.lastOutputDirError = String(message || "").trim();
|
||||
persistDesktopSettings();
|
||||
return desktopSettings.lastOutputDirError;
|
||||
}
|
||||
|
||||
function getOutputDirInfo() {
|
||||
loadDesktopSettings();
|
||||
const defaultPath = getDefaultOutputDir() || "";
|
||||
const currentPath = resolveOutputDir() || defaultPath;
|
||||
const hasPending = desktopSettings.pendingOutputDir !== null;
|
||||
const pendingPath =
|
||||
desktopSettings.pendingOutputDir === null
|
||||
? ""
|
||||
: desktopSettings.pendingOutputDir === ""
|
||||
? defaultPath
|
||||
: safeNormalizeDirectory(desktopSettings.pendingOutputDir);
|
||||
return {
|
||||
path: currentPath || "",
|
||||
defaultPath,
|
||||
isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath,
|
||||
pendingPath,
|
||||
hasPending,
|
||||
lastError: String(desktopSettings.lastOutputDirError || "").trim(),
|
||||
canChange: !!app.isPackaged,
|
||||
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
|
||||
};
|
||||
}
|
||||
|
||||
function getCloseBehavior() {
|
||||
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
|
||||
return v === "exit" ? "exit" : "tray";
|
||||
@@ -576,6 +743,137 @@ function setIgnoredUpdateVersion(version) {
|
||||
return desktopSettings.ignoredUpdateVersion;
|
||||
}
|
||||
|
||||
async function applyOutputDirChange(nextValue) {
|
||||
if (!app.isPackaged) {
|
||||
throw new Error("开发模式不支持界面修改 output 目录");
|
||||
}
|
||||
|
||||
const defaultPath = getDefaultOutputDir();
|
||||
const currentPath = resolveOutputDir();
|
||||
if (!defaultPath || !currentPath) {
|
||||
throw new Error("无法定位 output 目录");
|
||||
}
|
||||
|
||||
const rawText = String(nextValue ?? "").trim();
|
||||
const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath;
|
||||
const previousSettings = snapshotOutputDirSettings();
|
||||
|
||||
if (nextPath === currentPath) {
|
||||
setOutputDirSetting(nextPath);
|
||||
clearPendingOutputDirSetting();
|
||||
setOutputDirLastError("");
|
||||
ensureOutputLink();
|
||||
const info = getOutputDirInfo();
|
||||
return {
|
||||
success: true,
|
||||
changed: false,
|
||||
path: info.path,
|
||||
defaultPath: info.defaultPath,
|
||||
isDefault: info.isDefault,
|
||||
pendingPath: info.pendingPath,
|
||||
backupPath: "",
|
||||
sourceWasEmpty: false,
|
||||
message: "output 目录未变化",
|
||||
};
|
||||
}
|
||||
|
||||
const wasBackendRunning = !!backendProc;
|
||||
let migration = null;
|
||||
let settingsSwitched = false;
|
||||
|
||||
try {
|
||||
if (wasBackendRunning) {
|
||||
await stopBackendAndWait({ timeoutMs: 10_000 });
|
||||
}
|
||||
|
||||
migration = migrateOutputDirectory({
|
||||
currentDir: currentPath,
|
||||
nextDir: nextPath,
|
||||
});
|
||||
|
||||
setOutputDirSetting(nextPath);
|
||||
clearPendingOutputDirSetting();
|
||||
setOutputDirLastError("");
|
||||
settingsSwitched = true;
|
||||
ensureOutputLink();
|
||||
|
||||
if (wasBackendRunning) {
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
}
|
||||
|
||||
const info = getOutputDirInfo();
|
||||
return {
|
||||
success: true,
|
||||
changed: true,
|
||||
path: info.path,
|
||||
defaultPath: info.defaultPath,
|
||||
isDefault: info.isDefault,
|
||||
pendingPath: info.pendingPath,
|
||||
backupPath: migration?.backupDir || "",
|
||||
sourceWasEmpty: !!migration?.sourceWasEmpty,
|
||||
message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换",
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err?.message || String(err);
|
||||
let rollbackMessage = "";
|
||||
if (migration?.changed) {
|
||||
try {
|
||||
rollbackOutputDirectoryChange({
|
||||
previousDir: currentPath,
|
||||
currentDir: nextPath,
|
||||
backupDir: migration.backupDir,
|
||||
sourceWasEmpty: migration.sourceWasEmpty,
|
||||
});
|
||||
} catch (rollbackErr) {
|
||||
logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`);
|
||||
rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`;
|
||||
if (migration?.backupDir) {
|
||||
rollbackMessage += `;备份目录:${migration.backupDir}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsSwitched) {
|
||||
restoreOutputDirSettings(previousSettings);
|
||||
} else {
|
||||
syncOutputDirEnv(currentPath);
|
||||
}
|
||||
ensureOutputLink();
|
||||
|
||||
if (wasBackendRunning) {
|
||||
try {
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
} catch (restartErr) {
|
||||
throw new Error(
|
||||
`切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rollbackMessage) {
|
||||
throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyPendingOutputDirOnStartup() {
|
||||
if (!app.isPackaged) return;
|
||||
loadDesktopSettings();
|
||||
if (desktopSettings.pendingOutputDir === null) return;
|
||||
|
||||
try {
|
||||
await applyOutputDirChange(desktopSettings.pendingOutputDir);
|
||||
} catch (err) {
|
||||
clearPendingOutputDirSetting();
|
||||
setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`);
|
||||
ensureOutputLink();
|
||||
logMain(`[main] failed to apply pending output dir: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRendererCacheForPackagedUi() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
@@ -1171,11 +1469,11 @@ function startBackend() {
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
if (!env.WECHAT_TOOL_DATA_DIR) {
|
||||
env.WECHAT_TOOL_DATA_DIR = app.getPath("userData");
|
||||
}
|
||||
env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData");
|
||||
env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output");
|
||||
try {
|
||||
fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true });
|
||||
fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true });
|
||||
} catch {}
|
||||
|
||||
const backendExe = getPackagedBackendPath();
|
||||
@@ -1359,6 +1657,34 @@ function getRendererConsoleLogPath() {
|
||||
}
|
||||
}
|
||||
|
||||
function getRendererDebugLogPath() {
|
||||
try {
|
||||
const dir = app.getPath("userData");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, "renderer-debug.log");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendRendererDebugLog(line) {
|
||||
const logPath = getRendererDebugLogPath();
|
||||
if (!logPath) return;
|
||||
try {
|
||||
fs.appendFileSync(logPath, line, { encoding: "utf8" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stringifyDebugDetails(details) {
|
||||
if (details == null) return "";
|
||||
if (typeof details === "string") return details;
|
||||
try {
|
||||
return JSON.stringify(details);
|
||||
} catch (err) {
|
||||
return `[unserializable:${err?.message || err}]`;
|
||||
}
|
||||
}
|
||||
|
||||
function setupRendererConsoleLogging(win) {
|
||||
if (!debugEnabled()) return;
|
||||
|
||||
@@ -1380,6 +1706,62 @@ function setupRendererConsoleLogging(win) {
|
||||
});
|
||||
}
|
||||
|
||||
function setupRendererLifecycleLogging(win) {
|
||||
if (!debugEnabled()) return;
|
||||
|
||||
const logRendererLifecycle = (message) => {
|
||||
logMain(`[renderer] ${message}`);
|
||||
};
|
||||
|
||||
logRendererLifecycle(`window-created id=${win.id}`);
|
||||
|
||||
win.webContents.on("did-start-loading", () => {
|
||||
logRendererLifecycle("did-start-loading");
|
||||
});
|
||||
|
||||
win.webContents.on("dom-ready", () => {
|
||||
logRendererLifecycle(`dom-ready url=${win.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
win.webContents.on("did-stop-loading", () => {
|
||||
logRendererLifecycle("did-stop-loading");
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
logRendererLifecycle(`did-finish-load url=${win.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
logRendererLifecycle(
|
||||
`did-fail-load code=${errorCode} mainFrame=${!!isMainFrame} url=${validatedURL} error=${errorDescription}`
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate", (_event, url, httpResponseCode, httpStatusText) => {
|
||||
logRendererLifecycle(
|
||||
`did-navigate url=${url} code=${httpResponseCode || 0} status=${httpStatusText || ""}`
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
|
||||
logRendererLifecycle(`did-navigate-in-page mainFrame=${!!isMainFrame} url=${url}`);
|
||||
});
|
||||
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
logRendererLifecycle(
|
||||
`render-process-gone reason=${details?.reason || ""} exitCode=${details?.exitCode ?? ""}`
|
||||
);
|
||||
});
|
||||
|
||||
win.on("unresponsive", () => {
|
||||
logRendererLifecycle("window-unresponsive");
|
||||
});
|
||||
|
||||
win.on("responsive", () => {
|
||||
logRendererLifecycle("window-responsive");
|
||||
});
|
||||
}
|
||||
|
||||
function createMainWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -1423,18 +1805,26 @@ function createMainWindow() {
|
||||
});
|
||||
|
||||
setupRendererConsoleLogging(win);
|
||||
setupRendererLifecycleLogging(win);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
async function loadWithRetry(win, url) {
|
||||
const startedAt = Date.now();
|
||||
let attempt = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
logMain(`[main] loadWithRetry attempt=${attempt} url=${url}`);
|
||||
try {
|
||||
await win.loadURL(url);
|
||||
logMain(`[main] loadWithRetry success attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url}`);
|
||||
return;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logMain(
|
||||
`[main] loadWithRetry failure attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url} error=${err?.message || err}`
|
||||
);
|
||||
if (Date.now() - startedAt > 60_000) throw new Error(`Failed to load URL in time: ${url}`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
@@ -1502,6 +1892,24 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isDebugEnabled", () => {
|
||||
try {
|
||||
return debugEnabled();
|
||||
} catch (err) {
|
||||
logMain(`[main] app:isDebugEnabled failed: ${err?.message || err}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("debug:log", (event, payload) => {
|
||||
const scope = String(payload?.scope || "renderer").trim() || "renderer";
|
||||
const message = String(payload?.message || "").trim() || "(empty)";
|
||||
const url = String(payload?.url || event?.sender?.getURL?.() || "").trim();
|
||||
const details = stringifyDebugDetails(payload?.details);
|
||||
const suffix = details ? ` details=${details}` : "";
|
||||
appendRendererDebugLog(`[${nowIso()}] [${scope}] ${message} url=${url}${suffix}\n`);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
|
||||
try {
|
||||
const next = setCloseBehavior(behavior);
|
||||
@@ -1579,16 +1987,31 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getOutputDirInfo", () => {
|
||||
try {
|
||||
return getOutputDirInfo();
|
||||
} catch (err) {
|
||||
logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`);
|
||||
return {
|
||||
path: "",
|
||||
defaultPath: "",
|
||||
isDefault: true,
|
||||
pendingPath: "",
|
||||
hasPending: false,
|
||||
lastError: err?.message || String(err),
|
||||
canChange: !!app.isPackaged,
|
||||
changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getOutputDir", () => {
|
||||
const dir = resolveDataDir();
|
||||
if (!dir) return "";
|
||||
return path.join(dir, "output");
|
||||
return resolveOutputDir() || "";
|
||||
});
|
||||
|
||||
ipcMain.handle("app:openOutputDir", async () => {
|
||||
const dir = resolveDataDir();
|
||||
if (!dir) throw new Error("无法定位数据目录");
|
||||
const outDir = path.join(dir, "output");
|
||||
const outDir = resolveOutputDir();
|
||||
if (!outDir) throw new Error("无法定位 output 目录");
|
||||
try {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
} catch {}
|
||||
@@ -1603,6 +2026,28 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setOutputDir", async (_event, nextDir) => {
|
||||
if (outputDirChangeInProgress) {
|
||||
return {
|
||||
success: false,
|
||||
error: "output 目录切换中,请稍后重试",
|
||||
};
|
||||
}
|
||||
outputDirChangeInProgress = true;
|
||||
try {
|
||||
return await applyOutputDirChange(nextDir);
|
||||
} catch (err) {
|
||||
const message = err?.message || String(err);
|
||||
logMain(`[main] app:setOutputDir failed: ${message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
} finally {
|
||||
outputDirChangeInProgress = false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
|
||||
try {
|
||||
return getAccountInfoFromDisk(account);
|
||||
@@ -1686,6 +2131,8 @@ async function main() {
|
||||
// Resolve/create the data dir early so we can log reliably and place helper files
|
||||
// next to the installed exe for easier access.
|
||||
resolveDataDir();
|
||||
loadDesktopSettings();
|
||||
await applyPendingOutputDirOnStartup();
|
||||
ensureOutputLink();
|
||||
await ensureBackendPortAvailableOnStartup();
|
||||
|
||||
@@ -1727,6 +2174,7 @@ async function main() {
|
||||
process.env.ELECTRON_START_URL ||
|
||||
(app.isPackaged ? getBackendUiUrl() : "http://localhost:3000");
|
||||
|
||||
logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`);
|
||||
await loadWithRetry(win, startUrl);
|
||||
|
||||
// Auto-check updates after the UI has loaded (packaged builds only).
|
||||
@@ -1765,10 +2213,20 @@ if (gotSingleInstanceLock) {
|
||||
stopBackend();
|
||||
try {
|
||||
const dir = getUserDataDir();
|
||||
const outputDir = resolveOutputDir();
|
||||
if (dir) {
|
||||
const detailLines = [
|
||||
`启动失败:${err?.message || err}`,
|
||||
"",
|
||||
`桌面日志目录:${dir}`,
|
||||
"文件:desktop-main.log / backend-stdio.log",
|
||||
];
|
||||
if (outputDir) {
|
||||
detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里");
|
||||
}
|
||||
dialog.showErrorBox(
|
||||
"WeChatDataAnalysis 启动失败",
|
||||
`启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...`
|
||||
detailLines.join("\n")
|
||||
);
|
||||
shell.openPath(dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const SENTINEL_NAMES = [
|
||||
"account_keys.json",
|
||||
"runtime_settings.json",
|
||||
"message_edits.db",
|
||||
"databases",
|
||||
"exports",
|
||||
"logs",
|
||||
];
|
||||
|
||||
function normalizeDirectoryPath(value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~");
|
||||
if (!path.isAbsolute(expanded)) {
|
||||
throw new Error("output 目录必须使用绝对路径");
|
||||
}
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
function getDefaultOutputDirPath(dataDir) {
|
||||
const base = normalizeDirectoryPath(dataDir);
|
||||
if (!base) throw new Error("无法定位数据目录");
|
||||
return path.join(base, "output");
|
||||
}
|
||||
|
||||
function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) {
|
||||
const envPath = normalizeDirectoryPath(envOutputDir || "");
|
||||
if (envPath) return envPath;
|
||||
|
||||
const settingsPath = normalizeDirectoryPath(settingsOutputDir || "");
|
||||
if (settingsPath) return settingsPath;
|
||||
|
||||
return getDefaultOutputDirPath(dataDir);
|
||||
}
|
||||
|
||||
function hasDirectoryContents(dirPath) {
|
||||
try {
|
||||
return fs.readdirSync(dirPath).length > 0;
|
||||
} catch (err) {
|
||||
if (err && err.code === "ENOENT") return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function pathExists(dirPath) {
|
||||
try {
|
||||
fs.accessSync(dirPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(dirPath) {
|
||||
try {
|
||||
return fs.statSync(dirPath).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(parentPath, candidatePath) {
|
||||
const parent = path.resolve(parentPath);
|
||||
const candidate = path.resolve(candidatePath);
|
||||
if (parent === candidate) return false;
|
||||
const relative = path.relative(parent, candidate);
|
||||
return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function collectSentinels(sourceDir) {
|
||||
const sentinels = [];
|
||||
for (const name of SENTINEL_NAMES) {
|
||||
const sourcePath = path.join(sourceDir, name);
|
||||
if (!pathExists(sourcePath)) continue;
|
||||
sentinels.push({
|
||||
name,
|
||||
isDir: isDirectory(sourcePath),
|
||||
size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null,
|
||||
});
|
||||
}
|
||||
return sentinels;
|
||||
}
|
||||
|
||||
function verifyCopiedOutputTree(sourceDir, copiedDir) {
|
||||
const sentinels = collectSentinels(sourceDir);
|
||||
for (const item of sentinels) {
|
||||
const copiedPath = path.join(copiedDir, item.name);
|
||||
if (!pathExists(copiedPath)) {
|
||||
throw new Error(`迁移校验失败:缺少 ${item.name}`);
|
||||
}
|
||||
if (item.isDir) {
|
||||
if (!isDirectory(copiedPath)) {
|
||||
throw new Error(`迁移校验失败:${item.name} 不是目录`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const copiedStat = fs.statSync(copiedPath);
|
||||
if (copiedStat.size !== item.size) {
|
||||
throw new Error(`迁移校验失败:${item.name} 大小不一致`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeTimestamp(now = new Date()) {
|
||||
const parts = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, "0"),
|
||||
String(now.getDate()).padStart(2, "0"),
|
||||
String(now.getHours()).padStart(2, "0"),
|
||||
String(now.getMinutes()).padStart(2, "0"),
|
||||
String(now.getSeconds()).padStart(2, "0"),
|
||||
];
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function makeUniqueSiblingPath(basePath, suffix, now = new Date()) {
|
||||
const stamp = makeTimestamp(now);
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`;
|
||||
if (!pathExists(candidate)) return candidate;
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTargetIsUsable(targetDir) {
|
||||
if (!pathExists(targetDir)) return;
|
||||
if (!isDirectory(targetDir)) {
|
||||
throw new Error("目标 output 路径已存在且不是目录");
|
||||
}
|
||||
if (hasDirectoryContents(targetDir)) {
|
||||
throw new Error("目标 output 目录已有内容,请先清空后再重试");
|
||||
}
|
||||
}
|
||||
|
||||
function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) {
|
||||
const currentPath = normalizeDirectoryPath(currentDir);
|
||||
const targetPath = normalizeDirectoryPath(nextDir);
|
||||
if (!currentPath || !targetPath) {
|
||||
throw new Error("output 路径不能为空");
|
||||
}
|
||||
if (currentPath === targetPath) {
|
||||
return {
|
||||
changed: false,
|
||||
currentDir: currentPath,
|
||||
targetDir: targetPath,
|
||||
sourceWasEmpty: !hasDirectoryContents(currentPath),
|
||||
backupDir: "",
|
||||
};
|
||||
}
|
||||
if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) {
|
||||
throw new Error("新旧 output 路径不能互相包含");
|
||||
}
|
||||
|
||||
ensureTargetIsUsable(targetPath);
|
||||
|
||||
const sourceExists = pathExists(currentPath);
|
||||
if (sourceExists && !isDirectory(currentPath)) {
|
||||
throw new Error("当前 output 路径不是目录");
|
||||
}
|
||||
const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath);
|
||||
if (sourceWasEmpty) {
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
return {
|
||||
changed: true,
|
||||
currentDir: currentPath,
|
||||
targetDir: targetPath,
|
||||
sourceWasEmpty: true,
|
||||
backupDir: "",
|
||||
};
|
||||
}
|
||||
|
||||
const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now);
|
||||
const backupDir = makeUniqueSiblingPath(currentPath, "backup", now);
|
||||
|
||||
fs.cpSync(currentPath, tempTarget, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
|
||||
try {
|
||||
verifyCopiedOutputTree(currentPath, tempTarget);
|
||||
if (pathExists(targetPath)) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
fs.renameSync(currentPath, backupDir);
|
||||
try {
|
||||
fs.renameSync(tempTarget, targetPath);
|
||||
} catch (err) {
|
||||
try {
|
||||
if (!pathExists(currentPath) && pathExists(backupDir)) {
|
||||
fs.renameSync(backupDir, currentPath);
|
||||
}
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
if (pathExists(tempTarget)) {
|
||||
fs.rmSync(tempTarget, { recursive: true, force: true });
|
||||
}
|
||||
} catch {}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
currentDir: currentPath,
|
||||
targetDir: targetPath,
|
||||
sourceWasEmpty: false,
|
||||
backupDir,
|
||||
};
|
||||
}
|
||||
|
||||
function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) {
|
||||
const previousPath = normalizeDirectoryPath(previousDir);
|
||||
const currentPath = normalizeDirectoryPath(currentDir);
|
||||
|
||||
try {
|
||||
if (currentPath && pathExists(currentPath)) {
|
||||
fs.rmSync(currentPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (sourceWasEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupPath = normalizeDirectoryPath(backupDir);
|
||||
if (!backupPath || !pathExists(backupPath)) return;
|
||||
|
||||
try {
|
||||
if (!pathExists(previousPath)) {
|
||||
fs.renameSync(backupPath, previousPath);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
hasDirectoryContents,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
};
|
||||
@@ -1,5 +1,65 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
function sendDebugLog(scope, message, details) {
|
||||
try {
|
||||
ipcRenderer.send("debug:log", {
|
||||
scope: String(scope || "renderer"),
|
||||
message: String(message || ""),
|
||||
details: details == null ? {} : details,
|
||||
url: typeof location !== "undefined" ? String(location.href || "") : "",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sendDebugLog("preload", "script-start", {
|
||||
userAgent: typeof navigator !== "undefined" ? String(navigator.userAgent || "") : "",
|
||||
});
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("readystatechange", () => {
|
||||
sendDebugLog("preload", "document-readystate", {
|
||||
readyState: String(document.readyState || ""),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
sendDebugLog("preload", "dom-content-loaded");
|
||||
});
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
sendDebugLog("preload", "window-load");
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
sendDebugLog("preload", "window-error", {
|
||||
message: String(event?.message || ""),
|
||||
filename: String(event?.filename || ""),
|
||||
lineno: Number(event?.lineno || 0),
|
||||
colno: Number(event?.colno || 0),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event?.reason;
|
||||
sendDebugLog("preload", "window-unhandledrejection", {
|
||||
reason:
|
||||
reason instanceof Error
|
||||
? {
|
||||
name: String(reason.name || "Error"),
|
||||
message: String(reason.message || ""),
|
||||
stack: String(reason.stack || ""),
|
||||
}
|
||||
: String(reason || ""),
|
||||
});
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
sendDebugLog("preload", "set-timeout-0");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
// Marker used by the frontend to distinguish the Electron desktop shell from the pure web build.
|
||||
__brand: "WeChatDataAnalysisDesktop",
|
||||
@@ -7,6 +67,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
|
||||
close: () => ipcRenderer.invoke("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
|
||||
isDebugEnabled: () => ipcRenderer.invoke("app:isDebugEnabled"),
|
||||
logDebug: (scope, message, details = {}) => sendDebugLog(scope, message, details),
|
||||
|
||||
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
|
||||
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
|
||||
@@ -20,7 +82,9 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
|
||||
|
||||
// Data/output folder helpers
|
||||
getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"),
|
||||
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
|
||||
setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")),
|
||||
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
|
||||
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
|
||||
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const {
|
||||
getDefaultOutputDirPath,
|
||||
getEffectiveOutputDirPath,
|
||||
migrateOutputDirectory,
|
||||
normalizeDirectoryPath,
|
||||
rollbackOutputDirectoryChange,
|
||||
} = require("../src/output-dir.cjs");
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-"));
|
||||
}
|
||||
|
||||
function cleanupDir(dirPath) {
|
||||
try {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
test("normalizeDirectoryPath requires absolute paths", () => {
|
||||
assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/);
|
||||
});
|
||||
|
||||
test("getEffectiveOutputDirPath prefers env, then settings, then default", () => {
|
||||
const root = makeTempDir();
|
||||
const envDir = path.join(root, "env-output");
|
||||
const settingsDir = path.join(root, "settings-output");
|
||||
const defaultDir = path.join(root, "data", "output");
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
getEffectiveOutputDirPath({
|
||||
dataDir: path.join(root, "data"),
|
||||
envOutputDir: envDir,
|
||||
settingsOutputDir: settingsDir,
|
||||
}),
|
||||
path.resolve(envDir)
|
||||
);
|
||||
assert.equal(
|
||||
getEffectiveOutputDirPath({
|
||||
dataDir: path.join(root, "data"),
|
||||
envOutputDir: "",
|
||||
settingsOutputDir: settingsDir,
|
||||
}),
|
||||
path.resolve(settingsDir)
|
||||
);
|
||||
assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir));
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory switches empty source to a new directory", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(currentDir, { recursive: true });
|
||||
const result = migrateOutputDirectory({ currentDir, nextDir });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.sourceWasEmpty, true);
|
||||
assert.equal(result.backupDir, "");
|
||||
assert.ok(fs.existsSync(nextDir));
|
||||
assert.equal(fs.existsSync(currentDir), true);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory blocks non-empty targets", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}");
|
||||
fs.mkdirSync(nextDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied");
|
||||
|
||||
assert.throws(
|
||||
() => migrateOutputDirectory({ currentDir, nextDir }),
|
||||
/已有内容/
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory blocks invalid current paths", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.writeFileSync(currentDir, "not-a-directory");
|
||||
assert.throws(
|
||||
() => migrateOutputDirectory({ currentDir, nextDir }),
|
||||
/不是目录/
|
||||
);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => {
|
||||
const root = makeTempDir();
|
||||
const currentDir = path.join(root, "current-output");
|
||||
const nextDir = path.join(root, "custom-output");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true });
|
||||
fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}");
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session");
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact");
|
||||
|
||||
const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.sourceWasEmpty, false);
|
||||
assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/);
|
||||
assert.ok(fs.existsSync(nextDir));
|
||||
assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json")));
|
||||
assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db")));
|
||||
assert.ok(fs.existsSync(result.backupDir));
|
||||
assert.equal(fs.existsSync(currentDir), false);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
|
||||
test("rollbackOutputDirectoryChange restores the previous directory", () => {
|
||||
const root = makeTempDir();
|
||||
const previousDir = path.join(root, "current-output");
|
||||
const currentDir = path.join(root, "custom-output");
|
||||
const backupDir = path.join(root, "current-output.backup-20260330080100");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true });
|
||||
fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp");
|
||||
fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true });
|
||||
fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored");
|
||||
|
||||
rollbackOutputDirectoryChange({
|
||||
previousDir,
|
||||
currentDir,
|
||||
backupDir,
|
||||
sourceWasEmpty: false,
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(currentDir), false);
|
||||
assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db")));
|
||||
assert.equal(fs.existsSync(backupDir), false);
|
||||
} finally {
|
||||
cleanupDir(root);
|
||||
}
|
||||
});
|
||||
+19
-1
@@ -30,12 +30,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useThemeStore } from '~/stores/theme'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
const desktopUpdate = useDesktopUpdate()
|
||||
const { open: settingsDialogOpen, closeDialog: closeSettingsDialog } = useSettingsDialog()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
if (process.client) {
|
||||
themeStore.init()
|
||||
}
|
||||
|
||||
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
|
||||
// If we render different DOM on server vs client, Vue hydration will keep the
|
||||
@@ -71,6 +77,7 @@ onMounted(() => {
|
||||
const privacy = usePrivacyStore()
|
||||
void chatAccounts.ensureLoaded()
|
||||
privacy.init()
|
||||
themeStore.init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -78,7 +85,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
|
||||
const base = 'theme-app-shell'
|
||||
return isDesktop.value
|
||||
? `wechat-desktop h-screen flex overflow-hidden ${base}`
|
||||
: `h-screen flex overflow-hidden ${base}`
|
||||
@@ -126,4 +133,15 @@ const showSidebar = computed(() => {
|
||||
.wechat-desktop .wechat-desktop-content > .min-h-screen {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.theme-app-shell {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(7, 193, 96, 0.08), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(16, 174, 239, 0.08), transparent 36%),
|
||||
linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 45%, #dcfce7 100%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .theme-app-shell {
|
||||
background: var(--app-shell-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
+249
-92
@@ -24,7 +24,7 @@
|
||||
|
||||
.wechat-link-card.wechat-link-card--disabled:hover,
|
||||
.wechat-link-card-cover.wechat-link-card--disabled:hover {
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
@@ -33,17 +33,17 @@
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
|
||||
@@ -75,7 +75,25 @@
|
||||
|
||||
.session-list-resizer:hover::after,
|
||||
.session-list-resizer-active::after {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
background: var(--session-list-resizer);
|
||||
}
|
||||
|
||||
.msg-bubble.bubble-tail-r {
|
||||
background-color: var(--chat-bubble-sent) !important;
|
||||
color: var(--chat-bubble-sent-text) !important;
|
||||
}
|
||||
|
||||
.msg-bubble.bubble-tail-l {
|
||||
background-color: var(--chat-bubble-received) !important;
|
||||
color: var(--chat-bubble-received-text) !important;
|
||||
}
|
||||
|
||||
.bubble-tail-r::after {
|
||||
background: var(--chat-bubble-sent);
|
||||
}
|
||||
|
||||
.bubble-tail-l::after {
|
||||
background: var(--chat-bubble-received);
|
||||
}
|
||||
|
||||
/* 消息气泡样式 */
|
||||
@@ -87,7 +105,7 @@
|
||||
|
||||
/* 发送的消息(右侧绿色气泡) */
|
||||
.sent-message {
|
||||
background-color: #95EB69 !important;
|
||||
background-color: var(--chat-bubble-sent) !important;
|
||||
border-radius: var(--message-radius);
|
||||
}
|
||||
|
||||
@@ -99,13 +117,13 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #95EB69;
|
||||
background-color: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 接收的消息(左侧白色气泡) */
|
||||
.received-message {
|
||||
background-color: white !important;
|
||||
background-color: var(--chat-bubble-received) !important;
|
||||
border-radius: var(--message-radius);
|
||||
}
|
||||
|
||||
@@ -117,7 +135,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: white;
|
||||
background-color: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -172,7 +190,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #95EC69;
|
||||
background-color: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -188,7 +206,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: white;
|
||||
background-color: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -216,7 +234,8 @@
|
||||
}
|
||||
|
||||
.wechat-voice-sent {
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
color: var(--chat-bubble-sent-text);
|
||||
}
|
||||
|
||||
.wechat-voice-sent::after {
|
||||
@@ -227,12 +246,13 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-voice-received {
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
color: var(--chat-bubble-received-text);
|
||||
}
|
||||
|
||||
.wechat-voice-received::before {
|
||||
@@ -243,7 +263,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -259,7 +279,7 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: #1a1a1a;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wechat-quote-voice-icon {
|
||||
@@ -293,7 +313,7 @@
|
||||
|
||||
.wechat-voice-duration {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wechat-voice-unread {
|
||||
@@ -315,7 +335,8 @@
|
||||
}
|
||||
|
||||
.wechat-voip-sent {
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
color: var(--chat-bubble-sent-text);
|
||||
}
|
||||
|
||||
.wechat-voip-sent::after {
|
||||
@@ -326,12 +347,13 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-voip-received {
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
color: var(--chat-bubble-received-text);
|
||||
}
|
||||
|
||||
.wechat-voip-received::before {
|
||||
@@ -342,7 +364,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -362,16 +384,16 @@
|
||||
|
||||
.wechat-voip-text {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 统一特殊消息尾巴(红包 / 文件等) */
|
||||
:deep(.wechat-special-card) {
|
||||
.wechat-special-card {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.wechat-special-card)::after {
|
||||
.wechat-special-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
@@ -383,21 +405,21 @@
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
:deep(.wechat-special-sent-side)::after {
|
||||
.wechat-special-sent-side::after {
|
||||
left: auto;
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.wechat-chat-history-card {
|
||||
width: 210px;
|
||||
background: #ffffff;
|
||||
background: var(--merged-history-bg);
|
||||
border-radius: var(--message-radius);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.wechat-chat-history-card:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
.wechat-chat-history-body {
|
||||
@@ -407,13 +429,13 @@
|
||||
.wechat-chat-history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #161616;
|
||||
color: var(--merged-history-title);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wechat-chat-history-preview {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: var(--merged-history-preview);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -439,12 +461,17 @@
|
||||
left: 13px;
|
||||
right: 13px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-chat-history-bottom span {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
}
|
||||
|
||||
.wechat-quote-preview {
|
||||
background: var(--quote-bubble-bg);
|
||||
color: var(--quote-bubble-text);
|
||||
}
|
||||
|
||||
/* 转账消息样式 - 微信风格 */
|
||||
@@ -677,7 +704,7 @@
|
||||
/* 文件消息样式 - 基于红包样式覆盖 */
|
||||
.wechat-file-card {
|
||||
width: 210px;
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
@@ -701,11 +728,11 @@
|
||||
left: 13px;
|
||||
right: 13px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-file-card:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
.wechat-file-card .wechat-file-info {
|
||||
@@ -715,7 +742,7 @@
|
||||
|
||||
.wechat-file-name {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -726,7 +753,7 @@
|
||||
|
||||
.wechat-file-size {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -738,12 +765,16 @@
|
||||
}
|
||||
|
||||
.wechat-file-bottom {
|
||||
border-top: 1px solid #e8e8e8;
|
||||
border-top: 1px solid var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-file-bottom span {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
}
|
||||
|
||||
.wechat-file-card :is(.text-gray-500, .text-gray-400) {
|
||||
color: var(--merged-history-preview);
|
||||
}
|
||||
|
||||
.wechat-file-logo {
|
||||
@@ -754,11 +785,11 @@
|
||||
}
|
||||
|
||||
/* 链接消息样式 - 微信风格 */
|
||||
:deep(.wechat-link-card) {
|
||||
.wechat-link-card {
|
||||
width: 210px;
|
||||
min-width: 210px;
|
||||
max-width: 210px;
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@@ -770,11 +801,11 @@
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card:hover) {
|
||||
background: #f5f5f5;
|
||||
.wechat-link-card:hover {
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
:deep(.wechat-link-content) {
|
||||
.wechat-link-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -783,16 +814,16 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-summary) {
|
||||
.wechat-link-summary {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-title) {
|
||||
.wechat-link-title {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -801,9 +832,9 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-desc) {
|
||||
.wechat-link-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
color: var(--merged-history-preview);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -814,29 +845,29 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb) {
|
||||
.wechat-link-thumb {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
background: var(--app-surface-muted);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb-img) {
|
||||
.wechat-link-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card--mini-program) {
|
||||
.wechat-link-card--mini-program {
|
||||
max-height: 270px;
|
||||
height: 270px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-body) {
|
||||
.wechat-link-mini-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
@@ -846,14 +877,14 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header) {
|
||||
.wechat-link-mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar) {
|
||||
.wechat-link-mini-header-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
@@ -867,7 +898,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar-img) {
|
||||
.wechat-link-mini-header-avatar-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -876,9 +907,9 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-name) {
|
||||
.wechat-link-mini-header-name {
|
||||
font-size: 13px;
|
||||
color: #7d7d7d;
|
||||
color: var(--merged-history-preview);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -886,10 +917,10 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-title) {
|
||||
.wechat-link-mini-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -897,21 +928,21 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview) {
|
||||
.wechat-link-mini-preview {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
background: var(--app-surface-muted);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview--empty) {
|
||||
background: #f7f7f7;
|
||||
.wechat-link-mini-preview--empty {
|
||||
background: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview-img) {
|
||||
.wechat-link-mini-preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
@@ -919,7 +950,7 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer) {
|
||||
.wechat-link-mini-footer {
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -930,29 +961,29 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer)::before {
|
||||
.wechat-link-mini-footer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-icon) {
|
||||
.wechat-link-mini-footer-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-text) {
|
||||
.wechat-link-mini-footer-text {
|
||||
font-size: 10px;
|
||||
color: #8c8c8c;
|
||||
color: var(--merged-history-preview);
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from) {
|
||||
.wechat-link-from {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -962,17 +993,17 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from)::before {
|
||||
.wechat-link-from::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 11px;
|
||||
right: 11px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from-avatar) {
|
||||
.wechat-link-from-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
@@ -986,7 +1017,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from-avatar-img) {
|
||||
.wechat-link-from-avatar-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -995,20 +1026,20 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from-name) {
|
||||
.wechat-link-from-name {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 链接封面卡片(170x230 图 + 60 底栏) */
|
||||
:deep(.wechat-link-card-cover) {
|
||||
.wechat-link-card-cover {
|
||||
width: 137px;
|
||||
min-width: 137px;
|
||||
max-width: 137px;
|
||||
background: #fff;
|
||||
background: var(--merged-history-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@@ -1020,21 +1051,21 @@
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card-cover:hover) {
|
||||
background: #f5f5f5;
|
||||
.wechat-link-card-cover:hover {
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-image-wrap) {
|
||||
.wechat-link-cover-image-wrap {
|
||||
width: 137px;
|
||||
height: 180px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: #f2f2f2;
|
||||
background: var(--app-surface-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-image) {
|
||||
.wechat-link-cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -1043,11 +1074,11 @@
|
||||
}
|
||||
|
||||
/* 仅公众号封面卡片去掉菱形尖角,其它消息保持原样 */
|
||||
:deep(.wechat-link-card-cover.wechat-special-card)::after {
|
||||
.wechat-link-card-cover.wechat-special-card::after {
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-from) {
|
||||
.wechat-link-cover-from {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1062,7 +1093,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-from-avatar) {
|
||||
.wechat-link-cover-from-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
@@ -1076,7 +1107,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-from-avatar-img) {
|
||||
.wechat-link-cover-from-avatar-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
@@ -1085,7 +1116,7 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-from-name) {
|
||||
.wechat-link-cover-from-name {
|
||||
font-size: 12px;
|
||||
color: #f3f3f3;
|
||||
overflow: hidden;
|
||||
@@ -1093,13 +1124,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-cover-title) {
|
||||
.wechat-link-cover-title {
|
||||
height: 50px;
|
||||
padding: 7px 10px 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
line-height: 1.24;
|
||||
color: #1a1a1a;
|
||||
color: var(--merged-history-title);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -1108,6 +1139,132 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wechat-link-card-finder {
|
||||
width: 135px;
|
||||
min-width: 135px;
|
||||
max-width: 135px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wechat-link-card-finder.wechat-link-card--disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.wechat-link-finder-cover {
|
||||
width: 135px;
|
||||
height: 185px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: var(--app-surface-muted);
|
||||
}
|
||||
|
||||
.wechat-link-finder-cover--empty {
|
||||
background: linear-gradient(180deg, #37cc6a 0%, #118f42 100%);
|
||||
}
|
||||
|
||||
.wechat-link-finder-cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wechat-link-finder-cover-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.wechat-link-finder-cover-placeholder svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.wechat-link-finder-cover-shade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.12) 42%, rgba(0, 0, 0, 0.68) 100%);
|
||||
}
|
||||
|
||||
.wechat-link-finder-play {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -66%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.wechat-link-finder-play svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.wechat-link-finder-meta {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.wechat-link-finder-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 5px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.wechat-link-finder-author-avatar {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.wechat-link-finder-author-avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.wechat-link-finder-author-name {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
/* 隐私模式模糊效果 */
|
||||
.privacy-blur {
|
||||
filter: blur(9px);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-if="appStore.apiStatus !== 'connected'"
|
||||
class="fixed top-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm z-50">
|
||||
<div v-if="appStore.apiStatus !== 'connected'"
|
||||
class="api-status-banner fixed top-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm z-50"
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-red-600 mr-2 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
<template>
|
||||
<div class="biz-page h-full min-h-0 flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
|
||||
<div :class="['w-[300px] lg:w-[320px] border-r flex flex-col flex-shrink-0 z-10', isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-200']">
|
||||
<div class="p-3 border-b" :class="isDark ? 'border-[#333]' : 'border-gray-200'" style="background-color: var(--app-surface-muted)">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="contact-search-input"
|
||||
placeholder="搜索服务号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="loadingAccounts" class="flex justify-center py-4">
|
||||
<span class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">加载中...</span>
|
||||
</div>
|
||||
<div v-else class="pb-4">
|
||||
<div
|
||||
v-for="item in filteredAccounts"
|
||||
:key="item.username"
|
||||
@click="selectAccount(item)"
|
||||
class="flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b"
|
||||
:class="[
|
||||
isDark ? 'border-[#333]' : 'border-gray-50',
|
||||
selectedBizAccount?.username === item.username
|
||||
? (isDark ? 'bg-[#333]' : 'bg-[#E5E5E5]') // 选中状态
|
||||
: item.username === 'gh_3dfda90e39d6'
|
||||
? (isDark ? 'bg-[#2a2a2a] hover:bg-[#333]' : 'bg-[#F2F2F2] hover:bg-[#EAEAEA]') // 微信支付专门的底色
|
||||
: (isDark ? 'hover:bg-[#252525]' : 'hover:bg-gray-50') // 普通悬浮色
|
||||
]"
|
||||
>
|
||||
<img v-if="item.avatar" :src="api.getBizProxyImageUrl(item.avatar)" :class="['w-10 h-10 rounded-md object-cover flex-shrink-0', isDark ? 'bg-[#333]' : 'bg-gray-200']" alt=""/>
|
||||
<div v-else class="w-10 h-10 rounded-md bg-[#03C160] text-white flex items-center justify-center text-lg font-medium flex-shrink-0 shadow-sm">
|
||||
{{ (item.name || item.username).charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm truncate" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ item.name || item.username }}</h3>
|
||||
<span v-if="item.formatted_last_time" class="text-[11px] flex-shrink-0 ml-2" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
|
||||
{{ item.formatted_last_time }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-[10px] px-1.5 py-0.5 rounded w-max mt-0.5"
|
||||
:class="[
|
||||
item.type === 1 ? (isDark ? 'text-[#03C160] bg-[#03C160]/20' : 'text-[#03C160] bg-[#03C160]/10') : // 服务号
|
||||
item.type === 0 ? (isDark ? 'text-blue-400 bg-blue-900/40' : 'text-blue-500 bg-blue-50') : // 公众号
|
||||
item.type === 2 ? (isDark ? 'text-orange-400 bg-orange-900/40' : 'text-orange-500 bg-orange-50') : // 企业号
|
||||
(isDark ? 'text-gray-400 bg-gray-700/50' : 'text-gray-400 bg-gray-100') // 未知
|
||||
]"
|
||||
>
|
||||
{{ {1: '服务号', 0: '公众号', 2: '企业号', 3: '未知'}[item.type] || '未知' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0" :class="isDark ? 'bg-[#121212]' : 'bg-[#F5F5F5]'">
|
||||
<div v-if="selectedBizAccount" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="h-14 border-b flex items-center px-5 shrink-0 z-10" :class="isDark ? 'bg-[#121212] border-[#333]' : 'bg-[#F5F5F5] border-gray-200'">
|
||||
<h2 class="text-base" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ selectedBizAccount.name }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse" @scroll="handleScroll" ref="messageListRef">
|
||||
<div class="h-4 shrink-0" aria-hidden="true"></div>
|
||||
<div v-if="!hasMore" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">没有更多消息了</div>
|
||||
<div v-if="loadingMessages" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">正在加载...</div>
|
||||
|
||||
<div class="w-full max-w-[400px] mx-auto flex flex-col-reverse gap-6">
|
||||
<div v-for="msg in messages" :key="msg.local_id" class="w-full">
|
||||
|
||||
<div v-if="selectedBizAccount.username === 'gh_3dfda90e39d6'" class="rounded-xl shadow-sm p-5 border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
|
||||
<div class="flex items-center text-sm mb-5" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
|
||||
<img v-if="msg.merchant_icon" :src="api.getBizProxyImageUrl(msg.merchant_icon)" class="w-6 h-6 rounded-full mr-2 object-cover" alt=""/>
|
||||
<div v-else class="w-6 h-6 rounded-full mr-2 flex items-center justify-center" :class="isDark ? 'bg-green-900/40 text-green-400' : 'bg-green-100 text-green-600'">¥</div>
|
||||
<span>{{ msg.merchant_name || '微信支付' }}</span>
|
||||
</div>
|
||||
<div class="text-center mb-6">
|
||||
<h3 class="text-[22px] font-medium mb-1" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ msg.title }}</h3>
|
||||
</div>
|
||||
<div class="text-[13px] whitespace-pre-wrap leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
|
||||
{{ msg.description }}
|
||||
</div>
|
||||
<div class="mt-4 pt-3 border-t text-[12px] text-right" :class="isDark ? 'border-[#333] text-gray-500' : 'border-gray-100 text-gray-400'">
|
||||
{{ msg.formatted_time }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl shadow-sm overflow-hidden border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
|
||||
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
|
||||
<img :src="msg.cover ? api.getBizProxyImageUrl(msg.cover) : defaultImage" :class="['w-full h-[180px] object-cover', isDark ? 'bg-[#333]' : 'bg-gray-100']" alt=""/>
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 pt-8">
|
||||
<h3 class="text-white text-[15px] font-medium leading-snug line-clamp-2 group-hover:underline">
|
||||
{{ msg.title }}
|
||||
</h3>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div v-if="msg.des" class="px-4 py-3 text-[13px] border-b" :class="isDark ? 'text-gray-400 border-[#333]' : 'text-gray-500 border-gray-50'">
|
||||
{{ msg.des }}
|
||||
</div>
|
||||
|
||||
<div v-if="msg.content_list && msg.content_list.length > 1" class="flex flex-col">
|
||||
<a
|
||||
v-for="(item, idx) in msg.content_list.slice(1)"
|
||||
:key="idx"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
class="flex items-center justify-between p-3 border-t hover:bg-opacity-50 cursor-pointer group"
|
||||
:class="isDark ? 'border-[#333] hover:bg-[#252525]' : 'border-gray-100 hover:bg-gray-50'"
|
||||
>
|
||||
<span class="text-[14px] leading-snug line-clamp-2 pr-3 group-hover:underline" :class="isDark ? 'text-gray-200' : 'text-gray-800'">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<img :src="item.cover ? api.getBizProxyImageUrl(item.cover) : defaultImage" :class="['w-12 h-12 rounded object-cover flex-shrink-0 border', isDark ? 'bg-[#333] border-[#444]' : 'bg-gray-100 border-gray-100']" alt=""/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl flex items-center justify-center" :class="isDark ? 'bg-[#2a2a2a]' : 'bg-gray-200/50'">
|
||||
<svg class="w-10 h-10" :class="isDark ? 'text-gray-600' : 'text-gray-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5L18.5 7H20" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">请选择一个服务号查看消息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useApi } from '~/composables/useApi'
|
||||
const api = useApi()
|
||||
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useThemeStore } from '~/stores/theme'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
|
||||
const accounts = ref([])
|
||||
const loadingAccounts = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedBizAccount = ref(null)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const chatAccountsStore = useChatAccountsStore()
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { isDark } = storeToRefs(themeStore)
|
||||
const { selectedAccount: selectedDbAccount } = storeToRefs(chatAccountsStore)
|
||||
const { enabled: realtimeEnabled, changeSeq } = storeToRefs(realtimeStore)
|
||||
|
||||
const messages = ref([])
|
||||
const loadingMessages = ref(false)
|
||||
const offset = ref(0)
|
||||
const limit = 20
|
||||
const hasMore = ref(true)
|
||||
|
||||
const messageListRef = ref(null)
|
||||
let realtimeRefreshFuture = null
|
||||
let realtimeRefreshQueued = false
|
||||
|
||||
// 默认占位图
|
||||
// const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
|
||||
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
|
||||
|
||||
const getCurrentAccountParam = () => {
|
||||
const account = String(selectedDbAccount.value || '').trim()
|
||||
return account || undefined
|
||||
}
|
||||
|
||||
const resetMessagesState = () => {
|
||||
messages.value = []
|
||||
offset.value = 0
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
const fetchAccounts = async ({ preserveSelection = true } = {}) => {
|
||||
loadingAccounts.value = true
|
||||
const previousUsername = preserveSelection ? String(selectedBizAccount.value?.username || '').trim() : ''
|
||||
try {
|
||||
const res = await api.listBizAccounts({ account: getCurrentAccountParam() })
|
||||
const nextAccounts = Array.isArray(res?.data) ? res.data : []
|
||||
accounts.value = nextAccounts
|
||||
|
||||
if (previousUsername) {
|
||||
selectedBizAccount.value = nextAccounts.find(item => item.username === previousUsername) || null
|
||||
} else if (!selectedBizAccount.value?.username) {
|
||||
selectedBizAccount.value = null
|
||||
}
|
||||
} catch (err) {
|
||||
accounts.value = []
|
||||
selectedBizAccount.value = null
|
||||
console.error('获取服务号失败:', err)
|
||||
} finally {
|
||||
loadingAccounts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
const filteredAccounts = computed(() => {
|
||||
if (!searchQuery.value) return accounts.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return accounts.value.filter(a =>
|
||||
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||
(a.username && a.username.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
// 点击选择服务号
|
||||
const selectAccount = async (account) => {
|
||||
if (selectedBizAccount.value?.username === account.username) return
|
||||
selectedBizAccount.value = account
|
||||
|
||||
// 重置消息状态
|
||||
resetMessagesState()
|
||||
|
||||
await loadMessages()
|
||||
}
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = async () => {
|
||||
if (loadingMessages.value || !hasMore.value || !selectedBizAccount.value) return
|
||||
|
||||
loadingMessages.value = true
|
||||
try {
|
||||
const username = selectedBizAccount.value.username
|
||||
const params = {
|
||||
account: getCurrentAccountParam(),
|
||||
username,
|
||||
offset: offset.value,
|
||||
limit,
|
||||
}
|
||||
|
||||
let res
|
||||
if (username === 'gh_3dfda90e39d6') {
|
||||
res = await api.listBizPayRecords(params)
|
||||
} else {
|
||||
res = await api.listBizMessages(params)
|
||||
}
|
||||
|
||||
if (res && res.data) {
|
||||
if (res.data.length < limit) {
|
||||
hasMore.value = false
|
||||
}
|
||||
// 追加数据
|
||||
messages.value.push(...res.data)
|
||||
offset.value += limit
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('加载消息失败:', err)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reloadSelectedMessages = async () => {
|
||||
if (!selectedBizAccount.value) return
|
||||
resetMessagesState()
|
||||
await loadMessages()
|
||||
}
|
||||
|
||||
const syncAllBizRealtime = async ({ forceReload = false } = {}) => {
|
||||
const priorityUsername = String(selectedBizAccount.value?.username || '').trim()
|
||||
if (!realtimeEnabled.value) {
|
||||
if (forceReload) {
|
||||
await reloadSelectedMessages()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.syncChatRealtimeAll({
|
||||
account: getCurrentAccountParam(),
|
||||
max_scan: 200,
|
||||
priority_username: priorityUsername,
|
||||
priority_max_scan: 400,
|
||||
include_hidden: true,
|
||||
include_official: true,
|
||||
only_official: true,
|
||||
backfill_limit: 0,
|
||||
})
|
||||
const hasDelta = Number(result?.insertedTotal || 0) > 0 || Number(result?.sessionsUpdated || 0) > 0
|
||||
await fetchAccounts({ preserveSelection: true })
|
||||
if (selectedBizAccount.value?.username) {
|
||||
if (hasDelta || forceReload) {
|
||||
await reloadSelectedMessages()
|
||||
}
|
||||
} else if (forceReload) {
|
||||
resetMessagesState()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('实时同步服务号失败:', err)
|
||||
if (forceReload) {
|
||||
await fetchAccounts({ preserveSelection: true })
|
||||
await reloadSelectedMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queueRealtimeBizRefresh = () => {
|
||||
if (!realtimeEnabled.value) return
|
||||
if (realtimeRefreshFuture) {
|
||||
realtimeRefreshQueued = true
|
||||
return
|
||||
}
|
||||
|
||||
realtimeRefreshFuture = syncAllBizRealtime().finally(() => {
|
||||
realtimeRefreshFuture = null
|
||||
if (realtimeRefreshQueued) {
|
||||
realtimeRefreshQueued = false
|
||||
queueRealtimeBizRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 向上滚动加载逻辑
|
||||
// 因为容器设置了 flex-col-reverse,所以 scrollTop 越靠近负值(或0取决于浏览器)越是到了历史消息端
|
||||
// 但比较通用兼容的做法是监听 scroll,距离顶部或底部小于阈值时触发
|
||||
const handleScroll = (e) => {
|
||||
const target = e.target
|
||||
// 针对 flex-col-reverse: 滚动到底部实际上是视觉上的最上方(历史消息)
|
||||
// 当 scrollHeight - Math.abs(scrollTop) - clientHeight < 50 时加载
|
||||
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
|
||||
loadMessages()
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedDbAccount, async (next, prev) => {
|
||||
if (String(next || '').trim() === String(prev || '').trim()) return
|
||||
selectedBizAccount.value = null
|
||||
resetMessagesState()
|
||||
searchQuery.value = ''
|
||||
|
||||
if (!String(next || '').trim()) {
|
||||
accounts.value = []
|
||||
return
|
||||
}
|
||||
await fetchAccounts({ preserveSelection: false })
|
||||
if (realtimeEnabled.value) {
|
||||
await syncAllBizRealtime({ forceReload: true })
|
||||
}
|
||||
})
|
||||
|
||||
watch(changeSeq, (next, prev) => {
|
||||
if (!realtimeEnabled.value) return
|
||||
if (next === prev) return
|
||||
queueRealtimeBizRefresh()
|
||||
})
|
||||
|
||||
watch(realtimeEnabled, async (enabled, wasEnabled) => {
|
||||
if (enabled && !wasEnabled) {
|
||||
await syncAllBizRealtime({ forceReload: true })
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await chatAccountsStore.ensureLoaded()
|
||||
await fetchAccounts({ preserveSelection: false })
|
||||
if (realtimeEnabled.value) {
|
||||
await syncAllBizRealtime({ forceReload: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 隐藏滚动条但允许滚动(可选) */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -136,10 +136,19 @@ const openLocation = () => {
|
||||
|
||||
<style scoped>
|
||||
.wechat-location-card-wrap {
|
||||
--location-card-bg: var(--chat-bubble-received);
|
||||
--location-card-text: var(--chat-bubble-received-text);
|
||||
--location-card-muted: var(--chat-sender-name);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--sent {
|
||||
--location-card-bg: var(--chat-bubble-sent);
|
||||
--location-card-text: var(--chat-bubble-sent-text);
|
||||
--location-card-muted: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--received::before,
|
||||
.wechat-location-card-wrap--sent::after {
|
||||
content: '';
|
||||
@@ -147,7 +156,7 @@ const openLocation = () => {
|
||||
top: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -165,27 +174,27 @@ const openLocation = () => {
|
||||
overflow: hidden;
|
||||
border-radius: var(--message-radius);
|
||||
border: none;
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent {
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
}
|
||||
|
||||
.wechat-location-card__text {
|
||||
padding: 10px 12px 8px;
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__text {
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
}
|
||||
|
||||
.wechat-location-card__title {
|
||||
color: #111827;
|
||||
color: var(--location-card-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
@@ -197,7 +206,7 @@ const openLocation = () => {
|
||||
|
||||
.wechat-location-card__subtitle {
|
||||
margin-top: 4px;
|
||||
color: #9ca3af;
|
||||
color: var(--location-card-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
@@ -206,7 +215,7 @@ const openLocation = () => {
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__subtitle {
|
||||
color: #9ca3af;
|
||||
color: var(--location-card-muted);
|
||||
}
|
||||
|
||||
.wechat-location-card__map {
|
||||
@@ -258,4 +267,10 @@ const openLocation = () => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .wechat-location-card-wrap {
|
||||
--location-card-bg: var(--merged-history-bg);
|
||||
--location-card-text: var(--merged-history-title);
|
||||
--location-card-muted: var(--merged-history-preview);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@ const closeWindow = () => {
|
||||
<style scoped>
|
||||
.desktop-titlebar {
|
||||
height: var(--desktop-titlebar-height, 32px);
|
||||
background: #ededed;
|
||||
background: var(--desktop-titlebar-bg);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
@@ -92,11 +92,11 @@ const closeWindow = () => {
|
||||
}
|
||||
|
||||
.desktop-titlebar-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
background: var(--desktop-titlebar-hover);
|
||||
}
|
||||
|
||||
.desktop-titlebar-btn:active {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
background: var(--desktop-titlebar-active);
|
||||
}
|
||||
|
||||
.desktop-titlebar-btn-close:hover {
|
||||
@@ -122,7 +122,7 @@ const closeWindow = () => {
|
||||
/* Optical centering: the glyph was anchored to the bottom, so it looked low. */
|
||||
top: 5px;
|
||||
height: 1px;
|
||||
background: #111;
|
||||
background: var(--desktop-titlebar-icon);
|
||||
}
|
||||
|
||||
.desktop-titlebar-icon-maximize::before {
|
||||
@@ -132,7 +132,7 @@ const closeWindow = () => {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border: 1px solid #111;
|
||||
border: 1px solid var(--desktop-titlebar-icon);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ const closeWindow = () => {
|
||||
right: 1px;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
background: #111;
|
||||
background: var(--desktop-titlebar-icon);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="open && info" class="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="onBackdropClick" />
|
||||
|
||||
<div class="relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
|
||||
<div class="desktop-update-dialog-panel relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
|
||||
<button
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
type="button"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
|
||||
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
|
||||
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[880px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
|
||||
<aside class="flex w-[160px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
|
||||
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
|
||||
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -149,19 +149,74 @@
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">
|
||||
当前:{{ desktopOutputDirText }}
|
||||
<span class="ml-1 text-[#666]">{{ desktopOutputDirIsDefault ? '(默认位置)' : '(自定义位置)' }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">默认:{{ desktopOutputDirDefaultText }}</div>
|
||||
<div v-if="desktopOutputDirPendingText" class="mt-0.5 text-[11px] text-amber-700 break-words">
|
||||
待应用:{{ desktopOutputDirPendingText }}
|
||||
</div>
|
||||
<div v-if="desktopOutputDirUnavailableReason" class="mt-1 text-[11px] text-amber-700 break-words">
|
||||
{{ desktopOutputDirUnavailableReason }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopOutputDirLoading || desktopOutputDirApplying"
|
||||
@click="onDesktopOpenOutputDir"
|
||||
>
|
||||
打开当前 output
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center">
|
||||
<input
|
||||
v-model="desktopOutputDirInput"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
class="min-w-0 flex-1 rounded-[6px] border border-[#e2e2e2] bg-white px-2.5 py-1.5 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="desktopOutputDirControlsDisabled"
|
||||
:placeholder="desktopOutputDirCanChange ? '选择新的 output 目录' : '当前环境不支持修改 output 目录'"
|
||||
@keyup.enter="onDesktopOutputDirApply"
|
||||
/>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopOutputDirControlsDisabled"
|
||||
@click="onDesktopChooseOutputDir"
|
||||
>
|
||||
选择文件夹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopOutputDirControlsDisabled"
|
||||
@click="onDesktopOutputDirApply"
|
||||
>
|
||||
{{ desktopOutputDirApplying ? '迁移中...' : '应用' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopOutputDirControlsDisabled"
|
||||
@click="onDesktopOutputDirReset"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirCanChange" class="text-[11px] text-[#909090]">
|
||||
修改后会迁移整个 output 目录;如果目标目录已有内容,会先阻止并提示。
|
||||
</div>
|
||||
<div v-if="desktopOutputDirMessage" class="rounded-[6px] border border-[#d8efe2] bg-[#f4fbf7] px-2.5 py-1.5 text-[11px] text-[#1b6b43] whitespace-pre-wrap">
|
||||
{{ desktopOutputDirMessage }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopOutputDirLoading"
|
||||
@click="onDesktopOpenOutputDir"
|
||||
>
|
||||
打开 output
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopOutputDirError }}
|
||||
@@ -345,13 +400,33 @@ const desktopBackendPortError = ref('')
|
||||
const desktopBackendPortDefault = ref(10392)
|
||||
|
||||
const desktopOutputDir = ref('')
|
||||
const desktopOutputDirDefault = ref('')
|
||||
const desktopOutputDirInput = ref('')
|
||||
const desktopOutputDirPending = ref('')
|
||||
const desktopOutputDirLoading = ref(false)
|
||||
const desktopOutputDirApplying = ref(false)
|
||||
const desktopOutputDirError = ref('')
|
||||
const desktopOutputDirMessage = ref('')
|
||||
const desktopOutputDirIsDefault = ref(true)
|
||||
const desktopOutputDirCanChange = ref(true)
|
||||
const desktopOutputDirUnavailableReason = ref('')
|
||||
const desktopOutputDirText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopOutputDir.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
const desktopOutputDirDefaultText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopOutputDirDefault.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
const desktopOutputDirPendingText = computed(() => {
|
||||
const v = String(desktopOutputDirPending.value || '').trim()
|
||||
return v || ''
|
||||
})
|
||||
const desktopOutputDirControlsDisabled = computed(() => (
|
||||
!isDesktopEnv.value || !desktopOutputDirCanChange.value || desktopOutputDirLoading.value || desktopOutputDirApplying.value
|
||||
))
|
||||
|
||||
const desktopLogFilePath = ref('')
|
||||
const desktopLogFileLoading = ref(false)
|
||||
@@ -530,12 +605,33 @@ const refreshDesktopBackendPort = async () => {
|
||||
|
||||
const refreshDesktopOutputDir = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getOutputDir) return
|
||||
if (!window.wechatDesktop?.getOutputDir && !window.wechatDesktop?.getOutputDirInfo) return
|
||||
desktopOutputDirLoading.value = true
|
||||
desktopOutputDirError.value = ''
|
||||
try {
|
||||
if (window.wechatDesktop?.getOutputDirInfo) {
|
||||
const info = await window.wechatDesktop.getOutputDirInfo()
|
||||
desktopOutputDir.value = String(info?.path || '').trim()
|
||||
desktopOutputDirDefault.value = String(info?.defaultPath || '').trim()
|
||||
desktopOutputDirPending.value = String(info?.pendingPath || '').trim()
|
||||
desktopOutputDirIsDefault.value = !!info?.isDefault
|
||||
desktopOutputDirCanChange.value = info?.canChange !== false
|
||||
desktopOutputDirUnavailableReason.value = String(info?.changeUnavailableReason || '').trim()
|
||||
desktopOutputDirInput.value = desktopOutputDir.value || desktopOutputDirDefault.value
|
||||
if (info?.lastError) {
|
||||
desktopOutputDirError.value = String(info.lastError || '').trim()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const v = await window.wechatDesktop.getOutputDir()
|
||||
desktopOutputDir.value = String(v || '').trim()
|
||||
desktopOutputDirDefault.value = desktopOutputDir.value
|
||||
desktopOutputDirPending.value = ''
|
||||
desktopOutputDirIsDefault.value = true
|
||||
desktopOutputDirCanChange.value = false
|
||||
desktopOutputDirUnavailableReason.value = '当前桌面环境不支持修改 output 目录'
|
||||
desktopOutputDirInput.value = desktopOutputDir.value
|
||||
} catch (e) {
|
||||
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
|
||||
} finally {
|
||||
@@ -558,6 +654,62 @@ const onDesktopOpenOutputDir = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopChooseOutputDir = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.chooseDirectory) return
|
||||
desktopOutputDirError.value = ''
|
||||
desktopOutputDirMessage.value = ''
|
||||
try {
|
||||
const result = await window.wechatDesktop.chooseDirectory({ title: '选择新的 output 目录' })
|
||||
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
|
||||
desktopOutputDirInput.value = String(result.filePaths[0] || '').trim()
|
||||
}
|
||||
} catch (e) {
|
||||
desktopOutputDirError.value = e?.message || '选择 output 目录失败'
|
||||
}
|
||||
}
|
||||
|
||||
const applyDesktopOutputDir = async (nextDir) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setOutputDir) {
|
||||
desktopOutputDirError.value = '当前桌面环境不支持修改 output 目录'
|
||||
return
|
||||
}
|
||||
if (!desktopOutputDirCanChange.value) {
|
||||
desktopOutputDirError.value = desktopOutputDirUnavailableReason.value || '开发模式不支持界面修改 output 目录'
|
||||
return
|
||||
}
|
||||
desktopOutputDirApplying.value = true
|
||||
desktopOutputDirError.value = ''
|
||||
desktopOutputDirMessage.value = ''
|
||||
try {
|
||||
const res = await window.wechatDesktop.setOutputDir(String(nextDir ?? '').trim())
|
||||
if (res?.success === false) {
|
||||
desktopOutputDirError.value = String(res?.error || '修改 output 目录失败').trim()
|
||||
await refreshDesktopOutputDir()
|
||||
return
|
||||
}
|
||||
await refreshDesktopOutputDir()
|
||||
desktopOutputDirMessage.value = String(
|
||||
res?.message || (res?.changed === false ? 'output 目录未变化' : 'output 目录已更新')
|
||||
).trim()
|
||||
} catch (e) {
|
||||
desktopOutputDirError.value = e?.message || '修改 output 目录失败'
|
||||
await refreshDesktopOutputDir()
|
||||
} finally {
|
||||
desktopOutputDirApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopOutputDirApply = async () => {
|
||||
await applyDesktopOutputDir(desktopOutputDirInput.value)
|
||||
}
|
||||
|
||||
const onDesktopOutputDirReset = async () => {
|
||||
desktopOutputDirInput.value = desktopOutputDirDefault.value
|
||||
await applyDesktopOutputDir('')
|
||||
}
|
||||
|
||||
const refreshBackendLogFileInfo = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
desktopLogFileLoading.value = true
|
||||
@@ -702,6 +854,9 @@ const onDesktopCheckUpdates = async () => {
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await refreshBackendLogFileInfo()
|
||||
if (isDesktopEnv.value) {
|
||||
await refreshDesktopOutputDir()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-r border-gray-200 flex flex-col"
|
||||
:style="{ backgroundColor: '#e8e7e7', width: '60px', minWidth: '60px', maxWidth: '60px' }"
|
||||
class="sidebar-rail border-r flex flex-col"
|
||||
>
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- Avatar -->
|
||||
@@ -25,12 +24,12 @@
|
||||
|
||||
<!-- Chat -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="聊天"
|
||||
@click="goChat"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isChatRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isChatRoute }">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
|
||||
</svg>
|
||||
@@ -40,12 +39,12 @@
|
||||
|
||||
<!-- Edits -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="修改记录"
|
||||
@click="goEdits"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isEditsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isEditsRoute }">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
@@ -56,12 +55,12 @@
|
||||
|
||||
<!-- Moments -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="朋友圈"
|
||||
@click="goSns"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isSnsRoute }">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -86,12 +85,12 @@
|
||||
|
||||
<!-- Contacts -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="联系人"
|
||||
@click="goContacts"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isContactsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isContactsRoute }">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="10" cy="7" r="4" />
|
||||
@@ -102,14 +101,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="服务号"
|
||||
@click="goBiz"
|
||||
>
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isBizRoute }">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wrapped -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="年度总结"
|
||||
@click="goWrapped"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isWrappedRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isWrappedRoute }">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -132,15 +146,15 @@
|
||||
|
||||
<!-- Realtime -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
:class="realtimeBusy ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
:title="realtimeTitle"
|
||||
@click="toggleRealtime"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg
|
||||
class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
:class="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'"
|
||||
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
:class="{ 'sidebar-rail-icon-active': realtimeEnabled }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -156,12 +170,12 @@
|
||||
|
||||
<!-- Privacy -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyStore.toggle"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': privacyMode }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
@@ -169,15 +183,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
:title="themeStore.isDark ? '切换浅色模式' : '切换深色模式'"
|
||||
@click="themeStore.toggle"
|
||||
>
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg
|
||||
v-if="themeStore.isDark"
|
||||
class="sidebar-rail-icon sidebar-rail-icon-active w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<path d="M12 2.5v2.2M12 19.3v2.2M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2.5 12h2.2M19.3 12h2.2M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3c-.08.5-.12 1.01-.12 1.54a8.25 8.25 0 0 0 8.37 8.25c.52 0 1.03-.04 1.54-.12Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<!-- Guide -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="引导页"
|
||||
@click="goGuide"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 10.5L12 3l9 7.5" />
|
||||
<path d="M5 9.5V20h14V9.5" />
|
||||
<path d="M10 20v-6h4v6" />
|
||||
@@ -187,12 +238,12 @@
|
||||
|
||||
<!-- Settings -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="goSettings"
|
||||
title="设置"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': settingsDialogOpen }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -208,10 +259,10 @@
|
||||
|
||||
<div
|
||||
v-if="accountDialogOpen"
|
||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
|
||||
class="account-info-dialog fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
|
||||
@click.self="closeAccountDialog"
|
||||
>
|
||||
<div class="w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
|
||||
<div class="account-info-dialog-panel w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
|
||||
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
|
||||
<button
|
||||
@@ -289,6 +340,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { useThemeStore } from '~/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -298,6 +350,9 @@ const { selectedAccount } = storeToRefs(chatAccounts)
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.init()
|
||||
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
||||
@@ -439,34 +494,17 @@ const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
|
||||
const isBizRoute = computed(() => route.path?.startsWith('/biz')) // 新增
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
|
||||
const goEdits = async () => {
|
||||
await navigateTo('/edits')
|
||||
}
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goContacts = async () => {
|
||||
await navigateTo('/contacts')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
const goGuide = async () => {
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
const goSettings = () => {
|
||||
openSettingsDialog()
|
||||
}
|
||||
const goChat = async () => { await navigateTo('/chat') }
|
||||
const goEdits = async () => { await navigateTo('/edits') }
|
||||
const goSns = async () => { await navigateTo('/sns') }
|
||||
const goContacts = async () => { await navigateTo('/contacts') }
|
||||
const goBiz = async () => { await navigateTo('/biz') }
|
||||
const goWrapped = async () => { await navigateTo('/wrapped') }
|
||||
const goGuide = async () => { await navigateTo('/') }
|
||||
const goSettings = () => { openSettingsDialog() }
|
||||
|
||||
const onWindowKeydown = (event) => {
|
||||
if (event?.key !== 'Escape') return
|
||||
@@ -540,3 +578,30 @@ const toggleRealtime = async () => {
|
||||
await realtimeStore.toggle({ silent: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-rail {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
max-width: 60px;
|
||||
background-color: var(--sidebar-rail-bg);
|
||||
border-color: var(--sidebar-rail-border);
|
||||
}
|
||||
|
||||
.sidebar-rail-plate {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-rail-action:hover .sidebar-rail-plate {
|
||||
background-color: var(--sidebar-rail-hover);
|
||||
}
|
||||
|
||||
.sidebar-rail-icon {
|
||||
color: var(--sidebar-rail-icon-color);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-rail-icon-active {
|
||||
color: var(--sidebar-rail-icon-active-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
|
||||
<div
|
||||
v-if="messageSearchSenderDropdownOpen"
|
||||
class="absolute left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-50 overflow-hidden"
|
||||
class="chat-overlay-dropdown absolute left-0 right-0 mt-1 rounded-md z-50 overflow-hidden"
|
||||
>
|
||||
<div class="p-2 border-b border-gray-100">
|
||||
<input
|
||||
@@ -274,8 +274,8 @@
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-gray-50"
|
||||
:class="!messageSearchSender ? 'bg-gray-50' : ''"
|
||||
class="chat-overlay-option w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs"
|
||||
:class="!messageSearchSender ? 'chat-overlay-option--active' : ''"
|
||||
@click="selectMessageSearchSender('')"
|
||||
>
|
||||
<span class="w-6 h-6 rounded-md overflow-hidden bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] text-gray-500">
|
||||
@@ -298,8 +298,8 @@
|
||||
v-for="s in filteredMessageSearchSenderOptions"
|
||||
:key="s.username"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-gray-50"
|
||||
:class="messageSearchSender === s.username ? 'bg-gray-50' : ''"
|
||||
class="chat-overlay-option w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs"
|
||||
:class="messageSearchSender === s.username ? 'chat-overlay-option--active' : ''"
|
||||
@click="selectMessageSearchSender(s.username)"
|
||||
>
|
||||
<div class="w-6 h-6 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
@@ -560,29 +560,29 @@
|
||||
@mousedown="focusFloatingWindow(win.id)"
|
||||
>
|
||||
<div
|
||||
class="bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden border border-gray-200 flex flex-col"
|
||||
class="chat-floating-window rounded-xl overflow-hidden flex flex-col"
|
||||
:style="{ width: win.width + 'px', height: win.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="px-3 py-2 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between select-none cursor-move"
|
||||
class="chat-floating-window__header px-3 py-2 flex items-center justify-between select-none cursor-move"
|
||||
@mousedown.stop="startFloatingWindowDrag(win.id, $event)"
|
||||
@touchstart.stop="startFloatingWindowDrag(win.id, $event)"
|
||||
>
|
||||
<div class="text-sm text-[#161616] truncate min-w-0">{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}</div>
|
||||
<div class="chat-floating-window__title text-sm truncate min-w-0">{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5 flex-shrink-0"
|
||||
class="chat-floating-window__close p-2 rounded flex-shrink-0"
|
||||
@click.stop="closeFloatingWindow(win.id)"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-[#f7f7f7]">
|
||||
<div class="chat-floating-window__body flex-1 overflow-auto">
|
||||
<!-- Chat history window -->
|
||||
<template v-if="win.kind === 'chatHistory'">
|
||||
<div v-if="win.loading" class="text-xs text-gray-500 text-center py-2">加载中...</div>
|
||||
@@ -593,7 +593,7 @@
|
||||
<div
|
||||
v-for="(rec, idx) in win.records"
|
||||
:key="rec.id || idx"
|
||||
class="px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]"
|
||||
class="chat-floating-window__row px-4 py-3 flex gap-3"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img
|
||||
@@ -826,51 +826,51 @@
|
||||
<!-- 合并转发聊天记录弹窗 -->
|
||||
<div
|
||||
v-if="chatHistoryModalVisible"
|
||||
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||||
class="chat-history-modal-overlay fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||||
@click="closeChatHistoryModal"
|
||||
>
|
||||
<div
|
||||
class="w-[92vw] max-w-[560px] max-h-[80vh] bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden flex flex-col"
|
||||
class="chat-history-modal-panel w-[92vw] max-w-[560px] max-h-[80vh] rounded-xl shadow-xl overflow-hidden flex flex-col"
|
||||
@click.stop
|
||||
>
|
||||
<div class="px-4 py-3 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between">
|
||||
<div class="chat-history-modal-header px-4 py-3 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
v-if="chatHistoryModalStack.length"
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5 flex-shrink-0"
|
||||
class="chat-history-modal-icon-btn p-2 rounded flex-shrink-0"
|
||||
@click="goBackChatHistoryModal"
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="chat-history-modal-icon w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="text-sm text-[#161616] truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
|
||||
<div class="chat-history-modal-title text-sm truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5"
|
||||
class="chat-history-modal-icon-btn p-2 rounded"
|
||||
@click="closeChatHistoryModal"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="chat-history-modal-icon w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-[#f7f7f7]">
|
||||
<div v-if="!chatHistoryModalRecords.length" class="text-sm text-gray-500 text-center py-10">
|
||||
<div class="chat-history-modal-body flex-1 overflow-auto">
|
||||
<div v-if="!chatHistoryModalRecords.length" class="chat-history-modal-empty text-sm text-center py-10">
|
||||
没有可显示的聊天记录
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(rec, idx) in chatHistoryModalRecords"
|
||||
:key="rec.id || idx"
|
||||
class="px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]"
|
||||
class="chat-history-modal-row px-4 py-3 flex gap-3 border-b"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img
|
||||
@@ -892,12 +892,12 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
v-if="chatHistoryModalInfo?.isChatRoom && (rec.senderDisplayName || rec.sourcename)"
|
||||
class="text-xs text-gray-500 leading-none truncate mb-1"
|
||||
class="chat-history-modal-sender text-xs leading-none truncate mb-1"
|
||||
>
|
||||
{{ rec.senderDisplayName || rec.sourcename }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rec.fullTime || rec.sourcetime" class="text-xs text-gray-400 flex-shrink-0 leading-none">
|
||||
<div v-if="rec.fullTime || rec.sourcetime" class="chat-history-modal-time text-xs flex-shrink-0 leading-none">
|
||||
{{ rec.fullTime || rec.sourcetime }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1086,19 +1086,20 @@
|
||||
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="fixed z-[12000] bg-white border border-gray-200 rounded-md shadow-lg text-sm"
|
||||
ref="contextMenuElement"
|
||||
class="chat-context-menu fixed z-[12000] max-h-[calc(100vh-16px)] overflow-y-auto rounded-md text-sm"
|
||||
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onCopyMessageTextClick"
|
||||
>
|
||||
复制文本
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onCopyMessageJsonClick"
|
||||
>
|
||||
@@ -1106,14 +1107,14 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.renderType === 'quote' && contextMenu.message?.quoteServerId"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onLocateQuotedMessageClick"
|
||||
>
|
||||
定位引用消息
|
||||
</button>
|
||||
<button
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
:disabled="contextMenu.disabled"
|
||||
:class="contextMenu.disabled ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
@@ -1126,7 +1127,7 @@
|
||||
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onEditMessageClick"
|
||||
>
|
||||
@@ -1134,7 +1135,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onEditMessageFieldsClick"
|
||||
>
|
||||
@@ -1142,7 +1143,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.editStatus?.modified"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-red-600"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2 text-red-600"
|
||||
type="button"
|
||||
@click="onResetEditedMessageClick"
|
||||
>
|
||||
@@ -1150,7 +1151,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2"
|
||||
type="button"
|
||||
@click="onRepairMessageSenderAsMeClick"
|
||||
>
|
||||
@@ -1158,7 +1159,7 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.message?.id"
|
||||
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-orange-600"
|
||||
class="chat-context-menu__item block w-full text-left px-3 py-2 text-orange-600"
|
||||
type="button"
|
||||
@click="onFlipWechatMessageDirectionClick"
|
||||
>
|
||||
@@ -1170,7 +1171,7 @@
|
||||
<!-- 修改消息弹窗 -->
|
||||
<div v-if="messageEditModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="closeMessageEditModal"></div>
|
||||
<div class="relative w-[860px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="chat-edit-modal relative w-[860px] max-w-[95vw] rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
|
||||
<div class="text-base font-medium text-gray-900">{{ messageEditModal.mode === 'content' ? '修改消息' : '编辑源码' }}</div>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageEditModal">
|
||||
@@ -1217,7 +1218,7 @@
|
||||
<!-- 字段编辑弹窗 -->
|
||||
<div v-if="messageFieldsModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="closeMessageFieldsModal"></div>
|
||||
<div class="relative w-[920px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="chat-edit-modal relative w-[920px] max-w-[95vw] rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
|
||||
<div class="text-base font-medium text-gray-900">字段编辑</div>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageFieldsModal">
|
||||
@@ -1272,7 +1273,7 @@
|
||||
<!-- 导出弹窗 -->
|
||||
<div v-if="exportModalOpen" class="fixed inset-0 z-[11000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="closeExportModal"></div>
|
||||
<div class="relative w-[960px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="chat-export-modal relative w-[960px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
|
||||
<div class="text-base font-medium text-gray-900">导出聊天记录(离线 ZIP)</div>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-700" type="button" @click="closeExportModal">
|
||||
@@ -1289,52 +1290,77 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-wrap items-end gap-6">
|
||||
<div class="flex flex-wrap items-end gap-3 xl:flex-nowrap">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">范围</div>
|
||||
<div class="flex flex-wrap gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportScope === 'current' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="current" v-model="exportScope" class="hidden" />
|
||||
<span>当前会话</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportScope === 'selected' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="selected" v-model="exportScope" class="hidden" />
|
||||
<span>选择会话(批量)</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="exportScope === 'current' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
:disabled="!selectedContact?.username"
|
||||
@click="exportScope = 'current'"
|
||||
>
|
||||
当前会话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('all')"
|
||||
>
|
||||
全部 {{ exportContactCounts.total }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('groups')"
|
||||
>
|
||||
群聊 {{ exportContactCounts.groups }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('singles')"
|
||||
>
|
||||
单聊 {{ exportContactCounts.singles }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">格式</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'json' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'json' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="json" v-model="exportFormat" class="hidden" />
|
||||
<span>JSON</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'txt' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'txt' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="txt" v-model="exportFormat" class="hidden" />
|
||||
<span>TXT</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="html" v-model="exportFormat" class="hidden" />
|
||||
<span>HTML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[320px]">
|
||||
<div class="flex-1 min-w-[280px]">
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">时间范围(可选)</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<input
|
||||
v-model="exportStartLocal"
|
||||
type="datetime-local"
|
||||
class="px-2.5 py-1.5 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
class="px-2 py-1 text-xs rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
/>
|
||||
<span class="text-gray-400">-</span>
|
||||
<input
|
||||
v-model="exportEndLocal"
|
||||
type="datetime-local"
|
||||
class="px-2.5 py-1.5 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
class="px-2 py-1 text-xs rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1366,32 +1392,8 @@
|
||||
</div>
|
||||
|
||||
<div v-if="exportScope === 'selected'" class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200"
|
||||
:class="exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
|
||||
@click="exportListTab = 'all'"
|
||||
>
|
||||
全部 {{ exportContactCounts.total }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200"
|
||||
:class="exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
|
||||
@click="exportListTab = 'groups'"
|
||||
>
|
||||
群聊 {{ exportContactCounts.groups }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200"
|
||||
:class="exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
|
||||
@click="exportListTab = 'singles'"
|
||||
>
|
||||
单聊 {{ exportContactCounts.singles }}
|
||||
</button>
|
||||
<div class="ml-auto text-xs text-gray-500">点击 tab 筛选</div>
|
||||
<div class="mb-2 text-xs text-gray-500">
|
||||
点击上方范围可筛选并默认全选当前结果,再次点击可取消全选;下方整行可点选会话
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
@@ -1403,26 +1405,27 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="border border-gray-200 rounded-md max-h-56 overflow-y-auto">
|
||||
<div
|
||||
<label
|
||||
v-for="c in exportFilteredContacts"
|
||||
:key="c.username"
|
||||
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 hover:bg-gray-50"
|
||||
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 cursor-pointer transition-colors"
|
||||
:class="isExportContactSelected(c.username) ? 'bg-[#03C160]/5 hover:bg-[#03C160]/10' : 'hover:bg-gray-50'"
|
||||
>
|
||||
<input type="checkbox" :value="c.username" v-model="exportSelectedUsernames" />
|
||||
<input type="checkbox" :value="c.username" v-model="exportSelectedUsernames" class="cursor-pointer" />
|
||||
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img v-if="c.avatar" :src="c.avatar" :alt="c.name + '头像'" class="w-full h-full object-cover" referrerpolicy="no-referrer" @error="onAvatarError($event, c)" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-xs font-bold text-gray-600">
|
||||
{{ (c.name || c.username || '?').charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="text-sm text-gray-800 truncate">
|
||||
{{ c.name }}
|
||||
<span class="text-xs text-gray-500">{{ c.isGroup ? '(群)' : '' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ c.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div v-if="exportFilteredContacts.length === 0" class="px-3 py-3 text-sm text-gray-500">
|
||||
无匹配会话
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div class="conversation-pane flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="chat-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
|
||||
<h2 class="chat-header-title text-base font-medium" :class="{ 'privacy-blur': privacyMode }">
|
||||
{{ selectedContact ? selectedContact.name : '' }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<button
|
||||
v-if="showJumpToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
|
||||
class="jump-to-bottom-btn absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full border shadow flex items-center justify-center"
|
||||
title="回到最新"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
@@ -81,15 +81,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div v-else class="conversation-empty flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
<h3 class="conversation-empty-title text-base font-medium mb-1.5">选择一个会话</h3>
|
||||
<p class="conversation-empty-text text-sm">
|
||||
从左侧列表选择联系人查看聊天记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { defineComponent, h, ref, watch } from 'vue'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
|
||||
const finderLogoUrl = '/assets/images/wechat/channels-logo.svg'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LinkCard',
|
||||
props: {
|
||||
@@ -19,7 +21,15 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const fromAvatarImgOk = ref(false)
|
||||
const fromAvatarImgError = ref(false)
|
||||
const lastFromAvatarUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => String(props.fromAvatar || '').trim(),
|
||||
() => {
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const getFromText = () => {
|
||||
const raw = String(props.from || '').trim()
|
||||
@@ -43,16 +53,14 @@ export default defineComponent({
|
||||
return text ? (Array.from(text)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
|
||||
const isFinder = String(props.linkType || '').trim() === 'finder'
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
lastFromAvatarUrl.value = fromAvatarUrl
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
}
|
||||
|
||||
const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value
|
||||
const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value)
|
||||
const fromAvatarStyle = fromAvatarImgOk.value
|
||||
@@ -114,7 +122,7 @@ export default defineComponent({
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
background: 'var(--merged-history-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
@@ -138,9 +146,68 @@ export default defineComponent({
|
||||
)
|
||||
}
|
||||
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
if (isFinder) {
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card-finder',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '135px',
|
||||
minWidth: '135px',
|
||||
maxWidth: '135px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: ['wechat-link-finder-cover', !props.preview ? 'wechat-link-finder-cover--empty' : ''].filter(Boolean).join(' ') }, [
|
||||
props.preview
|
||||
? h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '视频号封面',
|
||||
class: 'wechat-link-finder-cover-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
})
|
||||
: h('div', { class: 'wechat-link-finder-cover-placeholder', 'aria-hidden': 'true' }, [
|
||||
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
|
||||
h('path', { d: 'M8 5v14l11-7z' })
|
||||
])
|
||||
]),
|
||||
h('div', { class: 'wechat-link-finder-cover-shade', 'aria-hidden': 'true' }),
|
||||
h('div', { class: 'wechat-link-finder-play', 'aria-hidden': 'true' }, [
|
||||
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
|
||||
h('path', { d: 'M8 5v14l11-7z' })
|
||||
])
|
||||
]),
|
||||
h('div', { class: 'wechat-link-finder-meta' }, [
|
||||
h('div', { class: 'wechat-link-finder-author' }, [
|
||||
h('div', { class: 'wechat-link-finder-author-avatar', 'aria-hidden': 'true' }, [
|
||||
h('img', {
|
||||
src: finderLogoUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-finder-author-avatar-img'
|
||||
})
|
||||
]),
|
||||
h('div', { class: 'wechat-link-finder-author-name' }, fromText || '视频号')
|
||||
])
|
||||
])
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
if (isMiniProgram) {
|
||||
return h(
|
||||
@@ -165,7 +232,7 @@ export default defineComponent({
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
background: 'var(--merged-history-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
@@ -234,7 +301,7 @@ export default defineComponent({
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
background: 'var(--merged-history-bg)',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="message.quoteTitle || message.quoteContent"
|
||||
class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start bg-[#e1e1e1]">
|
||||
class="wechat-quote-preview mt-[5px] px-2 text-xs rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start">
|
||||
<div class="py-2 min-w-0 flex-1">
|
||||
<div v-if="isQuotedVoice(message)" class="flex items-center gap-1 min-w-0">
|
||||
<span v-if="message.quoteTitle" class="truncate flex-shrink-0">{{ message.quoteTitle }}:</span>
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
:data-create-time="message.createTime"
|
||||
>
|
||||
<div v-if="message.showTimeDivider" class="flex justify-center mb-4">
|
||||
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
|
||||
<div class="message-time-divider px-3 py-1 text-xs">
|
||||
{{ message.timeDivider }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.renderType === 'system'" class="flex justify-center">
|
||||
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
|
||||
<div class="message-time-divider px-3 py-1 text-xs">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<div
|
||||
v-if="contactProfileCardOpen && contactProfileCardMessageId === String(message.id ?? '')"
|
||||
class="absolute z-40 w-[360px] max-w-[88vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden"
|
||||
class="chat-contact-card absolute z-40 w-[360px] max-w-[88vw] rounded-lg overflow-hidden"
|
||||
:class="message.isSent ? 'right-0 top-[calc(100%+8px)]' : 'left-0 top-[calc(100%+8px)]'"
|
||||
@mouseenter="onContactCardMouseEnter"
|
||||
@mouseleave="onMessageAvatarMouseLeave"
|
||||
@@ -110,7 +110,7 @@
|
||||
:class="[message.isSent ? 'items-end' : 'items-start', { 'privacy-blur': privacyMode }]"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'message')"
|
||||
>
|
||||
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="text-[11px] text-gray-500 mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
|
||||
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="message-sender-name text-[11px] mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
|
||||
{{ message.senderDisplayName }}
|
||||
</div>
|
||||
<div
|
||||
@@ -146,3 +146,40 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-contact-card {
|
||||
background-color: var(--app-surface-bg);
|
||||
border: 1px solid var(--app-border);
|
||||
color: var(--app-text-primary);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .chat-contact-card {
|
||||
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.chat-contact-card .bg-white {
|
||||
background-color: var(--app-surface-bg);
|
||||
}
|
||||
|
||||
.chat-contact-card [class*='bg-[#F6F6F6]'] {
|
||||
background-color: var(--app-surface-soft);
|
||||
}
|
||||
|
||||
.chat-contact-card .bg-gray-200 {
|
||||
background-color: var(--app-border-soft);
|
||||
}
|
||||
|
||||
.chat-contact-card :is(.border-gray-100, .border-gray-200, .border-gray-300) {
|
||||
border-color: var(--app-border);
|
||||
}
|
||||
|
||||
.chat-contact-card :is(.text-gray-900, .text-gray-800, .text-gray-700) {
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.chat-contact-card :is(.text-gray-600, .text-gray-500, .text-gray-400) {
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div ref="messageContainerRef" class="flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
|
||||
<div ref="messageContainerRef" class="message-list flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
|
||||
<div v-if="selectedContact && hasMoreMessages" class="flex justify-center mb-4">
|
||||
<div
|
||||
class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 text-gray-700 select-none"
|
||||
class="message-list-load-more text-xs px-3 py-1 rounded-md border select-none"
|
||||
:class="isLoadingMessages ? 'opacity-60' : 'hover:bg-gray-50 cursor-pointer'"
|
||||
@click="!isLoadingMessages && loadMoreMessages()"
|
||||
>
|
||||
@@ -10,13 +10,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingMessages && messages.length === 0" class="text-center text-sm text-gray-500 py-6">
|
||||
<div v-if="isLoadingMessages && messages.length === 0" class="message-list-status text-center text-sm py-6">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="messagesError" class="text-center text-sm text-red-500 py-6 whitespace-pre-wrap">
|
||||
{{ messagesError }}
|
||||
</div>
|
||||
<div v-else-if="messages.length === 0" class="text-center text-sm text-gray-500 py-6">
|
||||
<div v-else-if="messages.length === 0" class="message-list-status text-center text-sm py-6">
|
||||
暂无聊天记录
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
|
||||
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
|
||||
class="session-list-panel border-r flex flex-col min-h-0 shrink-0 relative"
|
||||
:style="{ '--session-list-width': sessionListWidth + 'px' }"
|
||||
>
|
||||
<!-- 拖动调整会话列表宽度 -->
|
||||
<div
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- 聊天列表 -->
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="session-list-search p-3 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="session-list-scroll flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
|
||||
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(80px/var(--dpr))]">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
|
||||
@@ -63,22 +63,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="contactsError" class="px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
|
||||
<div v-else-if="contactsError" class="session-list-status px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
|
||||
{{ contactsError }}
|
||||
</div>
|
||||
<div v-else-if="contacts.length === 0" class="px-3 py-2 text-sm text-gray-500">
|
||||
<div v-else-if="contacts.length === 0" class="session-list-status px-3 py-2 text-sm">
|
||||
暂无会话
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-else class="pb-4">
|
||||
<div v-for="contact in filteredContacts" :key="contact.id"
|
||||
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
|
||||
:class="contact.isTop
|
||||
? (selectedContact?.id === contact.id
|
||||
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
|
||||
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
|
||||
: (selectedContact?.id === contact.id
|
||||
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
|
||||
: 'hover:bg-[#eaeaea]')"
|
||||
class="session-list-item px-3 cursor-pointer transition-colors duration-150 h-[calc(80px/var(--dpr))] flex items-center"
|
||||
:class="{
|
||||
'session-list-item--top': contact.isTop,
|
||||
'session-list-item--selected': selectedContact?.id === contact.id
|
||||
}"
|
||||
@click="selectContact(contact)">
|
||||
<div class="flex items-center space-x-3 w-full">
|
||||
<!-- 联系人头像 -->
|
||||
@@ -101,12 +98,12 @@
|
||||
<!-- 联系人信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
|
||||
<h3 class="session-list-item-name text-sm truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
|
||||
<div class="flex items-center flex-shrink-0 ml-2">
|
||||
<span class="text-xs text-gray-500">{{ contact.lastMessageTime }}</span>
|
||||
<span class="session-list-item-time text-xs">{{ contact.lastMessageTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
|
||||
<p class="session-list-item-preview text-xs truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
|
||||
<span
|
||||
v-for="(seg, idx) in parseTextWithEmoji(
|
||||
(contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '') +
|
||||
@@ -121,7 +118,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ref, toRaw } from 'vue'
|
||||
import { nextTick, ref, toRaw } from 'vue'
|
||||
|
||||
const CONTEXT_MENU_MARGIN = 8
|
||||
|
||||
const initialContextMenu = () => ({
|
||||
visible: false,
|
||||
@@ -45,6 +47,7 @@ export const useChatEditing = ({
|
||||
locateMessageByServerId
|
||||
}) => {
|
||||
const contextMenu = ref(initialContextMenu())
|
||||
const contextMenuElement = ref(null)
|
||||
const messageEditModal = ref(initialMessageEditModal())
|
||||
const messageFieldsModal = ref(initialMessageFieldsModal())
|
||||
|
||||
@@ -52,6 +55,44 @@ export const useChatEditing = ({
|
||||
contextMenu.value = initialContextMenu()
|
||||
}
|
||||
|
||||
const repositionContextMenu = () => {
|
||||
if (!process.client || !contextMenu.value.visible) return
|
||||
const menuEl = contextMenuElement.value
|
||||
if (!menuEl) return
|
||||
|
||||
const rect = menuEl.getBoundingClientRect()
|
||||
const viewportWidth = Math.max(window.innerWidth || 0, document.documentElement?.clientWidth || 0)
|
||||
const viewportHeight = Math.max(window.innerHeight || 0, document.documentElement?.clientHeight || 0)
|
||||
if (!viewportWidth || !viewportHeight) return
|
||||
|
||||
const maxX = Math.max(CONTEXT_MENU_MARGIN, viewportWidth - rect.width - CONTEXT_MENU_MARGIN)
|
||||
const maxY = Math.max(CONTEXT_MENU_MARGIN, viewportHeight - rect.height - CONTEXT_MENU_MARGIN)
|
||||
const currentX = Number(contextMenu.value.x || 0)
|
||||
const currentY = Number(contextMenu.value.y || 0)
|
||||
const nextX = Math.min(Math.max(currentX, CONTEXT_MENU_MARGIN), maxX)
|
||||
const nextY = Math.min(Math.max(currentY, CONTEXT_MENU_MARGIN), maxY)
|
||||
|
||||
if (nextX !== currentX || nextY !== currentY) {
|
||||
contextMenu.value = {
|
||||
...contextMenu.value,
|
||||
x: nextX,
|
||||
y: nextY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleContextMenuReposition = () => {
|
||||
if (!process.client) return
|
||||
void nextTick(() => {
|
||||
const run = () => repositionContextMenu()
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(run)
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadContextMenuEditStatus = async (params) => {
|
||||
if (!process.client) return
|
||||
const account = String(params?.account || '').trim()
|
||||
@@ -67,16 +108,19 @@ export const useChatEditing = ({
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatus = response || { modified: false }
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
} catch {
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatus = null
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
} finally {
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatusLoading = false
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,6 +170,8 @@ export const useChatEditing = ({
|
||||
void loadContextMenuEditStatus({ account, username, message_id: messageId })
|
||||
}
|
||||
} catch {}
|
||||
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
|
||||
const prettyJson = (value) => {
|
||||
@@ -519,6 +565,7 @@ export const useChatEditing = ({
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
contextMenuElement,
|
||||
messageEditModal,
|
||||
messageFieldsModal,
|
||||
closeContextMenu,
|
||||
|
||||
@@ -73,20 +73,35 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
return Math.round(clamp01(done / total) * 100)
|
||||
})
|
||||
|
||||
const exportFilteredContacts = computed(() => {
|
||||
const query = String(exportSearchQuery.value || '').trim().toLowerCase()
|
||||
const normalizeExportSelectedUsernames = (list) => {
|
||||
const seen = new Set()
|
||||
return (Array.isArray(list) ? list : []).reduce((acc, item) => {
|
||||
const username = String(item || '').trim()
|
||||
if (!username || seen.has(username)) return acc
|
||||
seen.add(username)
|
||||
acc.push(username)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => {
|
||||
const normalizedQuery = String(query || '').trim().toLowerCase()
|
||||
let list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
|
||||
const tab = String(exportListTab.value || 'all')
|
||||
if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup)
|
||||
const normalizedTab = String(tab || 'all')
|
||||
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
if (normalizedTab === 'singles') list = list.filter((contact) => !contact?.isGroup)
|
||||
|
||||
if (!query) return list
|
||||
if (!normalizedQuery) return list
|
||||
return list.filter((contact) => {
|
||||
const name = String(contact?.name || '').toLowerCase()
|
||||
const username = String(contact?.username || '').toLowerCase()
|
||||
return name.includes(query) || username.includes(query)
|
||||
return name.includes(normalizedQuery) || username.includes(normalizedQuery)
|
||||
})
|
||||
}
|
||||
|
||||
const exportFilteredContacts = computed(() => {
|
||||
return getExportFilteredContacts()
|
||||
})
|
||||
|
||||
const exportContactCounts = computed(() => {
|
||||
@@ -96,6 +111,60 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
return { total, groups, singles: total - groups }
|
||||
})
|
||||
|
||||
const exportSelectedUsernameSet = computed(() => {
|
||||
return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value))
|
||||
})
|
||||
|
||||
const setExportSelectedUsernames = (list) => {
|
||||
exportSelectedUsernames.value = normalizeExportSelectedUsernames(list)
|
||||
}
|
||||
|
||||
const getExportFilteredUsernames = (tab = exportListTab.value) => {
|
||||
return getExportFilteredContacts({ tab })
|
||||
.map((contact) => String(contact?.username || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const selectExportFilteredContacts = (tab = exportListTab.value) => {
|
||||
setExportSelectedUsernames(getExportFilteredUsernames(tab))
|
||||
}
|
||||
|
||||
const clearExportFilteredContacts = () => {
|
||||
setExportSelectedUsernames([])
|
||||
}
|
||||
|
||||
const areExportFilteredContactsAllSelected = (tab = exportListTab.value) => {
|
||||
const usernames = getExportFilteredUsernames(tab)
|
||||
if (usernames.length !== exportSelectedUsernameSet.value.size) return false
|
||||
return usernames.every((username) => exportSelectedUsernameSet.value.has(username))
|
||||
}
|
||||
|
||||
const onExportListTabClick = (tab) => {
|
||||
const nextTab = String(tab || 'all')
|
||||
const isSameTab = String(exportListTab.value || 'all') === nextTab
|
||||
exportListTab.value = nextTab
|
||||
|
||||
if (isSameTab) {
|
||||
if (areExportFilteredContactsAllSelected(nextTab)) {
|
||||
clearExportFilteredContacts(nextTab)
|
||||
} else {
|
||||
selectExportFilteredContacts(nextTab)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectExportFilteredContacts(nextTab)
|
||||
}
|
||||
|
||||
const isExportContactSelected = (username) => {
|
||||
return exportSelectedUsernameSet.value.has(String(username || '').trim())
|
||||
}
|
||||
|
||||
const onExportBatchScopeClick = (tab) => {
|
||||
exportScope.value = 'selected'
|
||||
onExportListTabClick(tab)
|
||||
}
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
|
||||
}
|
||||
@@ -269,12 +338,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
exportSearchQuery.value = ''
|
||||
exportListTab.value = 'all'
|
||||
exportSelectedUsernames.value = []
|
||||
exportStartLocal.value = ''
|
||||
exportEndLocal.value = ''
|
||||
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
|
||||
exportAutoSavedFor.value = ''
|
||||
exportScope.value = selectedContact.value?.username ? 'current' : 'all'
|
||||
exportScope.value = selectedContact.value?.username ? 'current' : 'selected'
|
||||
if (!selectedContact.value?.username) {
|
||||
selectExportFilteredContacts('all')
|
||||
}
|
||||
}
|
||||
|
||||
const closeExportModal = () => {
|
||||
@@ -296,6 +370,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}
|
||||
})
|
||||
|
||||
watch(exportScope, (scope, previousScope) => {
|
||||
if (scope !== 'selected' || previousScope === 'selected') return
|
||||
if (exportSelectedUsernames.value.length > 0) return
|
||||
selectExportFilteredContacts(exportListTab.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
exportId: String(exportJob.value?.exportId || ''),
|
||||
@@ -447,6 +527,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportCurrentPercent,
|
||||
exportFilteredContacts,
|
||||
exportContactCounts,
|
||||
onExportBatchScopeClick,
|
||||
onExportListTabClick,
|
||||
isExportContactSelected,
|
||||
hasWebExportFolder,
|
||||
chooseExportFolder,
|
||||
getExportDownloadUrl,
|
||||
|
||||
@@ -27,13 +27,45 @@ export const useChatMessages = ({
|
||||
const messageContainerRef = ref(null)
|
||||
const activeMessagesFor = ref('')
|
||||
const showJumpToBottom = ref(false)
|
||||
let lastRenderMessagesFingerprint = ''
|
||||
|
||||
const isDesktopRenderer = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const logMessagePhase = (phase, details = {}) => {
|
||||
const payload = {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||||
activeMessagesFor: String(activeMessagesFor.value || '').trim(),
|
||||
...details
|
||||
}
|
||||
|
||||
if (isDesktopRenderer()) {
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-messages', phase, payload)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.info(`[chat-messages] ${phase}`, payload)
|
||||
}
|
||||
|
||||
const summarizeRenderTypes = (list) => {
|
||||
const counts = {}
|
||||
for (const item of Array.isArray(list) ? list : []) {
|
||||
const key = String(item?.renderType || 'unknown').trim() || 'unknown'
|
||||
counts[key] = Number(counts[key] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const previewImageUrl = ref(null)
|
||||
const previewVideoUrl = ref(null)
|
||||
const previewVideoPosterUrl = ref('')
|
||||
const previewVideoError = ref('')
|
||||
|
||||
const voiceRefs = ref({})
|
||||
const voiceRefs = new Map()
|
||||
const currentPlayingVoice = ref(null)
|
||||
const playingVoiceId = ref(null)
|
||||
|
||||
@@ -42,6 +74,7 @@ export const useChatMessages = ({
|
||||
let highlightTimer = null
|
||||
|
||||
const messageTypeFilter = ref('all')
|
||||
const localMediaVersion = ref(0)
|
||||
const messageTypeFilterOptions = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'text', label: '文本' },
|
||||
@@ -63,9 +96,39 @@ export const useChatMessages = ({
|
||||
const normalizeMessage = createMessageNormalizer({
|
||||
apiBase,
|
||||
getSelectedAccount: () => selectedAccount.value,
|
||||
getSelectedContact: () => selectedContact.value
|
||||
getSelectedContact: () => selectedContact.value,
|
||||
getLocalMediaVersion: () => localMediaVersion.value
|
||||
})
|
||||
|
||||
const bumpLocalMediaVersion = () => {
|
||||
localMediaVersion.value = (localMediaVersion.value + 1) % 1000000000
|
||||
return localMediaVersion.value
|
||||
}
|
||||
|
||||
const renormalizeLoadedMessages = (username) => {
|
||||
const key = String(username || '').trim()
|
||||
if (!key) return
|
||||
const existing = allMessages.value[key]
|
||||
if (!Array.isArray(existing) || !existing.length) return
|
||||
|
||||
const refreshed = dedupeMessagesById(existing.map((message) => {
|
||||
const normalized = normalizeMessage(message)
|
||||
return {
|
||||
...message,
|
||||
...normalized,
|
||||
_emojiDownloading: !!message?._emojiDownloading,
|
||||
_emojiDownloaded: typeof message?._emojiDownloaded === 'boolean' ? message._emojiDownloaded : normalized._emojiDownloaded,
|
||||
_quoteImageError: false,
|
||||
_quoteThumbError: false
|
||||
}
|
||||
}))
|
||||
|
||||
allMessages.value = {
|
||||
...allMessages.value,
|
||||
[key]: refreshed
|
||||
}
|
||||
}
|
||||
|
||||
const messages = computed(() => {
|
||||
if (!selectedContact.value) return []
|
||||
return allMessages.value[selectedContact.value.username] || []
|
||||
@@ -113,8 +176,16 @@ export const useChatMessages = ({
|
||||
const renderMessages = computed(() => {
|
||||
const list = messages.value || []
|
||||
const reverseSides = !!reverseMessageSides.value
|
||||
const fingerprint = `${String(selectedContact.value?.username || '').trim()}:${list.length}:${reverseSides ? '1' : '0'}`
|
||||
const shouldLogRender = isDesktopRenderer() && fingerprint !== lastRenderMessagesFingerprint
|
||||
if (shouldLogRender) {
|
||||
logMessagePhase('renderMessages:start', {
|
||||
count: list.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
let previousTs = 0
|
||||
return list.map((message) => {
|
||||
const rendered = list.map((message) => {
|
||||
const ts = Number(message.createTime || 0)
|
||||
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
|
||||
if (ts) previousTs = ts
|
||||
@@ -127,6 +198,14 @@ export const useChatMessages = ({
|
||||
timeDivider: formatTimeDivider(ts)
|
||||
}
|
||||
})
|
||||
if (shouldLogRender) {
|
||||
lastRenderMessagesFingerprint = fingerprint
|
||||
logMessagePhase('renderMessages:end', {
|
||||
count: rendered.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
return rendered
|
||||
})
|
||||
|
||||
const updateJumpToBottomState = () => {
|
||||
@@ -195,18 +274,16 @@ export const useChatMessages = ({
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return
|
||||
if (element) {
|
||||
voiceRefs.value = { ...voiceRefs.value, [key]: element }
|
||||
} else if (voiceRefs.value[key]) {
|
||||
const next = { ...voiceRefs.value }
|
||||
delete next[key]
|
||||
voiceRefs.value = next
|
||||
voiceRefs.set(key, element)
|
||||
} else {
|
||||
voiceRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const playVoiceById = async (voiceId) => {
|
||||
const key = String(voiceId || '').trim()
|
||||
if (!key) return
|
||||
const audio = voiceRefs.value[key]
|
||||
const audio = voiceRefs.get(key)
|
||||
if (!audio) return
|
||||
|
||||
try {
|
||||
@@ -333,6 +410,10 @@ export const useChatMessages = ({
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
logMessagePhase('loadMessages:enter', {
|
||||
username,
|
||||
reset
|
||||
})
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
activeMessagesFor.value = username
|
||||
@@ -357,13 +438,48 @@ export const useChatMessages = ({
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
logMessagePhase('loadMessages:request:start', {
|
||||
username,
|
||||
reset,
|
||||
offset,
|
||||
existingCount: existing.length,
|
||||
renderTypeFilter: messageTypeFilter.value,
|
||||
realtime: !!realtimeEnabled.value
|
||||
})
|
||||
const response = await api.listChatMessages(params)
|
||||
logMessagePhase('loadMessages:request:end', {
|
||||
username,
|
||||
reset,
|
||||
rawCount: Array.isArray(response?.messages) ? response.messages.length : 0,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
const raw = response?.messages || []
|
||||
logMessagePhase('loadMessages:normalize:start', {
|
||||
username,
|
||||
rawCount: raw.length
|
||||
})
|
||||
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
|
||||
logMessagePhase('loadMessages:normalize:end', {
|
||||
username,
|
||||
mappedCount: mapped.length,
|
||||
renderTypeCounts: summarizeRenderTypes(mapped)
|
||||
})
|
||||
|
||||
if (activeMessagesFor.value !== username) return
|
||||
if (activeMessagesFor.value !== username) {
|
||||
logMessagePhase('loadMessages:abort-stale', {
|
||||
username,
|
||||
activeMessagesFor: activeMessagesFor.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logMessagePhase('loadMessages:state-commit:start', {
|
||||
username,
|
||||
reset,
|
||||
mappedCount: mapped.length
|
||||
})
|
||||
if (reset) {
|
||||
allMessages.value = { ...allMessages.value, [username]: mapped }
|
||||
} else {
|
||||
@@ -380,6 +496,10 @@ export const useChatMessages = ({
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:state-commit:end', {
|
||||
username,
|
||||
storedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
|
||||
messagesMeta.value = {
|
||||
...messagesMeta.value,
|
||||
@@ -388,8 +508,20 @@ export const useChatMessages = ({
|
||||
hasMore: response?.hasMore
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:meta-commit:end', {
|
||||
username,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
logMessagePhase('loadMessages:nextTick:start', {
|
||||
username
|
||||
})
|
||||
await nextTick()
|
||||
logMessagePhase('loadMessages:nextTick:end', {
|
||||
username,
|
||||
renderedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer) {
|
||||
if (reset) {
|
||||
@@ -400,10 +532,28 @@ export const useChatMessages = ({
|
||||
}
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
logMessagePhase('loadMessages:scroll:end', {
|
||||
username,
|
||||
hasContainer: !!nextContainer,
|
||||
scrollTop: nextContainer ? nextContainer.scrollTop : null,
|
||||
scrollHeight: nextContainer ? nextContainer.scrollHeight : null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[chat-messages] loadMessages:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
reset: !!reset,
|
||||
error
|
||||
})
|
||||
messagesError.value = error?.message || '加载聊天记录失败'
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
logMessagePhase('loadMessages:exit', {
|
||||
username,
|
||||
reset,
|
||||
loading: isLoadingMessages.value,
|
||||
error: messagesError.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,9 +565,17 @@ export const useChatMessages = ({
|
||||
|
||||
const refreshSelectedMessages = async () => {
|
||||
if (!selectedContact.value) return
|
||||
bumpLocalMediaVersion()
|
||||
await loadMessages({ username: selectedContact.value.username, reset: true })
|
||||
}
|
||||
|
||||
const refreshCurrentMessageMedia = async () => {
|
||||
if (!selectedContact.value?.username) return
|
||||
bumpLocalMediaVersion()
|
||||
renormalizeLoadedMessages(selectedContact.value.username)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
|
||||
if (searchContext.value?.active || isLoadingMessages.value) return
|
||||
@@ -441,28 +599,38 @@ export const useChatMessages = ({
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
|
||||
const response = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
try {
|
||||
const response = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
|
||||
const latest = (response?.messages || []).map(normalizeMessage)
|
||||
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const newOnes = []
|
||||
for (const message of latest) {
|
||||
const id = String(message?.id || '')
|
||||
if (!id || seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
newOnes.push(message)
|
||||
const rawMessages = response?.messages || []
|
||||
const latest = rawMessages.map(normalizeMessage)
|
||||
|
||||
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const newOnes = []
|
||||
for (const message of latest) {
|
||||
const id = String(message?.id || '')
|
||||
if (!id || seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
newOnes.push(message)
|
||||
}
|
||||
if (!newOnes.length) return
|
||||
|
||||
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer && atBottom) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
} catch (error) {
|
||||
console.error('[chat-messages] refreshRealtimeIncremental:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
error
|
||||
})
|
||||
}
|
||||
if (!newOnes.length) return
|
||||
|
||||
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer && atBottom) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
}
|
||||
|
||||
let realtimeRefreshFuture = null
|
||||
@@ -491,7 +659,18 @@ export const useChatMessages = ({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const clearVoicePlaybackState = () => {
|
||||
try {
|
||||
currentPlayingVoice.value?.pause?.()
|
||||
if (currentPlayingVoice.value) currentPlayingVoice.value.currentTime = 0
|
||||
} catch {}
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
voiceRefs.clear()
|
||||
}
|
||||
|
||||
const resetMessageState = () => {
|
||||
clearVoicePlaybackState()
|
||||
allMessages.value = {}
|
||||
messagesMeta.value = {}
|
||||
messagesError.value = ''
|
||||
@@ -700,6 +879,7 @@ export const useChatMessages = ({
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = null
|
||||
clearContactProfileHoverHideTimer()
|
||||
clearVoicePlaybackState()
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -771,6 +951,7 @@ export const useChatMessages = ({
|
||||
loadMessages,
|
||||
loadMoreMessages,
|
||||
refreshSelectedMessages,
|
||||
refreshCurrentMessageMedia,
|
||||
refreshRealtimeIncremental,
|
||||
queueRealtimeRefresh,
|
||||
tryEnableRealtimeAuto,
|
||||
|
||||
@@ -250,7 +250,11 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
try {
|
||||
const hadLoadedAccountSnapshot = !!chatAccounts.loaded
|
||||
await chatAccounts.ensureLoaded()
|
||||
if (!selectedAccount.value && hadLoadedAccountSnapshot) {
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
}
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
|
||||
|
||||
@@ -205,6 +205,8 @@ export const useApi = () => {
|
||||
if (params && params.priority_max_scan != null) query.set('priority_max_scan', String(params.priority_max_scan))
|
||||
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
|
||||
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
|
||||
if (params && params.only_official != null) query.set('only_official', String(!!params.only_official))
|
||||
if (params && params.backfill_limit != null) query.set('backfill_limit', String(params.backfill_limit))
|
||||
const url = '/chat/realtime/sync_all' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url, { method: 'POST' })
|
||||
}
|
||||
@@ -561,6 +563,44 @@ export const useApi = () => {
|
||||
return await request('/get_image_key')
|
||||
}
|
||||
|
||||
// 枚举服务号信息
|
||||
const listBizAccounts = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
const url = '/biz/list' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 获取普通服务号消息
|
||||
const listBizMessages = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.username) query.set('username', params.username)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
const url = '/biz/messages' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
// 获取微信支付记录
|
||||
const listBizPayRecords = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
if (params && params.limit != null) query.set('limit', String(params.limit))
|
||||
if (params && params.offset != null) query.set('offset', String(params.offset))
|
||||
const url = '/biz/pay_records' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const getBizProxyImageUrl = (url) => {
|
||||
if (!url) return ''
|
||||
if (url.startsWith('data:')) return url // 如果已经是 base64,不处理
|
||||
const query = new URLSearchParams()
|
||||
query.set('url', url)
|
||||
const base = baseURL ? baseURL.replace(/\/$/, '') : ''
|
||||
return `${base}/biz/proxy_image?${query.toString()}`
|
||||
}
|
||||
|
||||
return {
|
||||
detectWechat,
|
||||
detectCurrentAccount,
|
||||
@@ -616,5 +656,9 @@ export const useApi = () => {
|
||||
getKeys,
|
||||
getImageKey,
|
||||
getWxStatus,
|
||||
listBizAccounts,
|
||||
listBizMessages,
|
||||
listBizPayRecords,
|
||||
getBizProxyImageUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,12 @@ const buildAccountMediaUrl = (apiBase, path, parts) => {
|
||||
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
|
||||
}
|
||||
|
||||
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
|
||||
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact, getLocalMediaVersion }) => {
|
||||
return (msg) => {
|
||||
const account = String(getSelectedAccount?.() || '').trim()
|
||||
const contact = getSelectedContact?.() || null
|
||||
const username = String(contact?.username || '').trim()
|
||||
const localMediaVersion = Number(getLocalMediaVersion?.() || 0)
|
||||
const isSent = !!msg.isSent
|
||||
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
|
||||
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
|
||||
@@ -66,7 +67,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
|
||||
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
`username=${encodeURIComponent(username)}`,
|
||||
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
@@ -86,7 +88,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
|
||||
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
`username=${encodeURIComponent(username)}`,
|
||||
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
@@ -158,7 +161,8 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
|
||||
username ? `username=${encodeURIComponent(username)}` : ''
|
||||
username ? `username=${encodeURIComponent(username)}` : '',
|
||||
localMediaVersion > 0 ? `v=${encodeURIComponent(String(localMediaVersion))}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
@@ -178,8 +182,10 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
localId: Number(msg.localId || 0),
|
||||
serverId: msg.serverId || 0,
|
||||
serverIdStr,
|
||||
type: Number(msg.type || 0),
|
||||
sender,
|
||||
senderUsername: msg.senderUsername || '',
|
||||
senderDisplayName: msg.senderDisplayName || '',
|
||||
@@ -188,7 +194,6 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
fullTime: formatMessageFullTime(msg.createTime),
|
||||
createTime: Number(msg.createTime || 0),
|
||||
isSent,
|
||||
type: 'text',
|
||||
renderType: msg.renderType || 'text',
|
||||
voipType: msg.voipType || '',
|
||||
title: msg.title || '',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const UI_THEME_KEY = 'ui.theme'
|
||||
export const UI_THEME_LIGHT = 'light'
|
||||
export const UI_THEME_DARK = 'dark'
|
||||
|
||||
export const normalizeUiTheme = (value, fallback = UI_THEME_LIGHT) => {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (normalized === UI_THEME_DARK) return UI_THEME_DARK
|
||||
if (normalized === UI_THEME_LIGHT) return UI_THEME_LIGHT
|
||||
return fallback === UI_THEME_DARK ? UI_THEME_DARK : UI_THEME_LIGHT
|
||||
}
|
||||
|
||||
export const readUiTheme = (fallback = UI_THEME_LIGHT) => {
|
||||
if (!process.client) return normalizeUiTheme(fallback)
|
||||
try {
|
||||
const raw = localStorage.getItem(UI_THEME_KEY)
|
||||
return normalizeUiTheme(raw, fallback)
|
||||
} catch {
|
||||
return normalizeUiTheme(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
export const writeUiTheme = (theme) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(UI_THEME_KEY, normalizeUiTheme(theme))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const applyUiTheme = (theme) => {
|
||||
if (!process.client || typeof document === 'undefined') return
|
||||
const normalized = normalizeUiTheme(theme)
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = normalized
|
||||
root.classList.toggle('theme-dark', normalized === UI_THEME_DARK)
|
||||
root.style.colorScheme = normalized === UI_THEME_DARK ? 'dark' : 'light'
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex overflow-hidden bg-white">
|
||||
<div class="flex-1 min-w-0">
|
||||
<BizMessages />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import BizMessages from "../components/BizMessages.vue";
|
||||
|
||||
useHead({
|
||||
title: '服务号消息 - WeChatDataAnalysis'
|
||||
})
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="chat-page-shell h-screen flex overflow-hidden">
|
||||
<SessionListPanel :state="chatState" />
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="chat-page-main flex-1 flex flex-col min-h-0">
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<ConversationPane :state="chatState" />
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ definePageMeta({
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: '??????? - ????????'
|
||||
title: '聊天记录 - 微信数据库解密工具'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
@@ -58,6 +58,67 @@ const routeUsername = computed(() => {
|
||||
return (Array.isArray(raw) ? raw[0] : raw) || ''
|
||||
})
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const desktopDebugEnabled = ref(false)
|
||||
const chatBootstrapStartedAt = process.client && typeof performance !== 'undefined' ? performance.now() : 0
|
||||
let messageLoadSequence = 0
|
||||
let firstSelectContactLogged = false
|
||||
let firstLoadMessagesLogged = false
|
||||
|
||||
const resolveDesktopDebugEnabled = async () => {
|
||||
if (!isDesktopShell() || typeof window.wechatDesktop?.isDebugEnabled !== 'function') {
|
||||
desktopDebugEnabled.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
desktopDebugEnabled.value = !!(await window.wechatDesktop.isDebugEnabled())
|
||||
} catch {
|
||||
desktopDebugEnabled.value = false
|
||||
}
|
||||
|
||||
return desktopDebugEnabled.value
|
||||
}
|
||||
|
||||
const chatBootstrapElapsedMs = () => {
|
||||
if (!process.client || typeof performance === 'undefined') return null
|
||||
const elapsed = performance.now() - chatBootstrapStartedAt
|
||||
return Number.isFinite(elapsed) ? Number(elapsed.toFixed(1)) : null
|
||||
}
|
||||
|
||||
const shouldLogChatBootstrap = () => isDesktopShell() || desktopDebugEnabled.value
|
||||
|
||||
const logChatBootstrap = (phase, details = {}) => {
|
||||
if (!shouldLogChatBootstrap()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-bootstrap', phase, details)
|
||||
} catch {}
|
||||
console.info(`[chat-bootstrap] ${phase}`, {
|
||||
elapsedMs: chatBootstrapElapsedMs(),
|
||||
route: route.fullPath,
|
||||
...details
|
||||
})
|
||||
}
|
||||
|
||||
const waitForNextPaint = async () => {
|
||||
await nextTick()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
await new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.setTimeout(resolve, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const nextMessageLoadToken = () => {
|
||||
messageLoadSequence += 1
|
||||
return messageLoadSequence
|
||||
}
|
||||
|
||||
const buildChatPath = (username) => {
|
||||
return username ? `/chat/${encodeURIComponent(username)}` : '/chat'
|
||||
}
|
||||
@@ -155,6 +216,7 @@ const {
|
||||
loadMessages,
|
||||
loadMoreMessages,
|
||||
refreshSelectedMessages,
|
||||
refreshCurrentMessageMedia,
|
||||
queueRealtimeRefresh,
|
||||
tryEnableRealtimeAuto,
|
||||
resetMessageState,
|
||||
@@ -184,17 +246,83 @@ const {
|
||||
|
||||
let exitSearchContext = async () => {}
|
||||
|
||||
const runMessageLoad = async ({ username, reset = true, deferUntilPaint = false, reason = '', token = nextMessageLoadToken() } = {}) => {
|
||||
const nextUsername = String(username || '').trim()
|
||||
if (!nextUsername) return false
|
||||
|
||||
if (deferUntilPaint) {
|
||||
logChatBootstrap('loadMessages:scheduled', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token
|
||||
})
|
||||
await waitForNextPaint()
|
||||
if (token !== messageLoadSequence) {
|
||||
logChatBootstrap('loadMessages:skipped-stale', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstLoad = !firstLoadMessagesLogged
|
||||
if (isFirstLoad) {
|
||||
firstLoadMessagesLogged = true
|
||||
}
|
||||
|
||||
logChatBootstrap(isFirstLoad ? 'loadMessages:first:start' : 'loadMessages:start', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token,
|
||||
reset
|
||||
})
|
||||
|
||||
await loadMessages({ username: nextUsername, reset })
|
||||
|
||||
logChatBootstrap(isFirstLoad ? 'loadMessages:first:end' : 'loadMessages:end', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token,
|
||||
renderedMessages: messages.value.length
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const selectContact = async (contact, options = {}) => {
|
||||
if (!contact) return
|
||||
const selectionReason = String(options.reason || 'manual-select').trim() || 'manual-select'
|
||||
const loadToken = nextMessageLoadToken()
|
||||
const nextUsername = contact?.username || ''
|
||||
if (searchContext.value?.active && searchContext.value.username && searchContext.value.username !== nextUsername) {
|
||||
await exitSearchContext()
|
||||
}
|
||||
|
||||
const isFirstSelect = !firstSelectContactLogged
|
||||
if (isFirstSelect) {
|
||||
firstSelectContactLogged = true
|
||||
}
|
||||
logChatBootstrap(isFirstSelect ? 'selectContact:first' : 'selectContact', {
|
||||
username: nextUsername,
|
||||
reason: selectionReason,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
skipLoadMessages: !!options.skipLoadMessages,
|
||||
syncRoute: options.syncRoute !== false
|
||||
})
|
||||
|
||||
selectedContact.value = contact
|
||||
if (!nextUsername) return
|
||||
|
||||
if (!options.skipLoadMessages) {
|
||||
loadMessages({ username: nextUsername, reset: true })
|
||||
void runMessageLoad({
|
||||
username: nextUsername,
|
||||
reset: true,
|
||||
deferUntilPaint: !!options.deferLoadMessages,
|
||||
reason: selectionReason,
|
||||
token: loadToken
|
||||
})
|
||||
}
|
||||
|
||||
if (options.syncRoute !== false && nextUsername) {
|
||||
@@ -205,24 +333,34 @@ const selectContact = async (contact, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const applyRouteSelection = async () => {
|
||||
const applyRouteSelection = async (options = {}) => {
|
||||
if (!contacts.value || contacts.value.length === 0) {
|
||||
selectedContact.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const selectionReason = String(options.reason || 'route-selection').trim() || 'route-selection'
|
||||
const requested = routeUsername.value || ''
|
||||
if (requested) {
|
||||
const matched = contacts.value.find((contact) => contact.username === requested)
|
||||
if (matched) {
|
||||
if (selectedContact.value?.username !== matched.username) {
|
||||
await selectContact(matched, { syncRoute: false })
|
||||
await selectContact(matched, {
|
||||
syncRoute: false,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
reason: `${selectionReason}:matched-route`
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await selectContact(contacts.value[0], { syncRoute: true, replaceRoute: true })
|
||||
await selectContact(contacts.value[0], {
|
||||
syncRoute: true,
|
||||
replaceRoute: true,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
reason: `${selectionReason}:fallback-first-contact`
|
||||
})
|
||||
}
|
||||
|
||||
const searchState = useChatSearch({
|
||||
@@ -363,18 +501,32 @@ const queueRealtimeSessionsRefresh = () => {
|
||||
}
|
||||
|
||||
const onAccountChange = async () => {
|
||||
logChatBootstrap('accountChange:start', {
|
||||
selectedAccount: selectedAccount.value
|
||||
})
|
||||
try {
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
await loadSessionsForSelectedAccount()
|
||||
} catch (error) {
|
||||
contactsError.value = error?.message || '???????'
|
||||
contactsError.value = error?.message || '加载会话失败'
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
}
|
||||
|
||||
resetAccountScopedState()
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('accountChange:applyRouteSelection:start', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
await applyRouteSelection({
|
||||
reason: 'account-change'
|
||||
})
|
||||
logChatBootstrap('accountChange:end', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
selectedUsername: selectedContact.value?.username || '',
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
}
|
||||
|
||||
const onGlobalClick = (event) => {
|
||||
@@ -417,9 +569,38 @@ const onGlobalKeyDown = (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
let lastResumeMediaRefreshAt = 0
|
||||
|
||||
const maybeRefreshMediaOnResume = () => {
|
||||
if (!process.client) return
|
||||
if (!selectedContact.value?.username) return
|
||||
if (searchContext.value?.active) return
|
||||
|
||||
const now = Date.now()
|
||||
if ((now - lastResumeMediaRefreshAt) < 1200) return
|
||||
lastResumeMediaRefreshAt = now
|
||||
void refreshCurrentMessageMedia()
|
||||
}
|
||||
|
||||
const onWindowFocus = () => {
|
||||
maybeRefreshMediaOnResume()
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
maybeRefreshMediaOnResume()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client) return
|
||||
|
||||
await resolveDesktopDebugEnabled()
|
||||
logChatBootstrap('route mount start', {
|
||||
requestedUsername: routeUsername.value,
|
||||
selectedAccount: selectedAccount.value,
|
||||
desktopShell: isDesktopShell()
|
||||
})
|
||||
|
||||
document.addEventListener('click', onGlobalClick)
|
||||
document.addEventListener('keydown', onGlobalKeyDown)
|
||||
document.addEventListener('mousemove', onFloatingWindowMouseMove)
|
||||
@@ -427,10 +608,46 @@ onMounted(async () => {
|
||||
document.addEventListener('touchmove', onFloatingWindowMouseMove)
|
||||
document.addEventListener('touchend', onFloatingWindowMouseUp)
|
||||
document.addEventListener('touchcancel', onFloatingWindowMouseUp)
|
||||
window.addEventListener('focus', onWindowFocus)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
logChatBootstrap('loadContacts:start', {
|
||||
selectedAccount: selectedAccount.value
|
||||
})
|
||||
await loadContacts()
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('loadContacts:end', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
|
||||
const deferInitialConversationBoot = isDesktopShell()
|
||||
await waitForNextPaint()
|
||||
logChatBootstrap('first render completion', {
|
||||
contactCount: contacts.value.length,
|
||||
deferInitialConversationBoot
|
||||
})
|
||||
|
||||
logChatBootstrap('applyRouteSelection:start', {
|
||||
requestedUsername: routeUsername.value,
|
||||
deferLoadMessages: deferInitialConversationBoot
|
||||
})
|
||||
await applyRouteSelection({
|
||||
deferLoadMessages: deferInitialConversationBoot,
|
||||
reason: deferInitialConversationBoot ? 'initial-route-post-paint' : 'initial-route'
|
||||
})
|
||||
logChatBootstrap('applyRouteSelection:end', {
|
||||
selectedUsername: selectedContact.value?.username || '',
|
||||
requestedUsername: routeUsername.value
|
||||
})
|
||||
|
||||
logChatBootstrap('tryEnableRealtimeAuto:start', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
realtimeEnabled: realtimeEnabled.value
|
||||
})
|
||||
await tryEnableRealtimeAuto()
|
||||
logChatBootstrap('tryEnableRealtimeAuto:end', {
|
||||
realtimeEnabled: realtimeEnabled.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -443,6 +660,8 @@ onUnmounted(() => {
|
||||
document.removeEventListener('touchmove', onFloatingWindowMouseMove)
|
||||
document.removeEventListener('touchend', onFloatingWindowMouseUp)
|
||||
document.removeEventListener('touchcancel', onFloatingWindowMouseUp)
|
||||
window.removeEventListener('focus', onWindowFocus)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
if (locateServerIdTimer) clearTimeout(locateServerIdTimer)
|
||||
locateServerIdTimer = null
|
||||
@@ -488,11 +707,17 @@ watch(messageTypeFilter, async (next, prev) => {
|
||||
|
||||
watch(
|
||||
routeUsername,
|
||||
async () => {
|
||||
async (next, prev) => {
|
||||
if (!process.client) return
|
||||
if (isLoadingContacts.value) return
|
||||
if (!contacts.value.length) return
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('routeUsername:change', {
|
||||
previousUsername: prev || '',
|
||||
nextUsername: next || ''
|
||||
})
|
||||
await applyRouteSelection({
|
||||
reason: 'route-watch'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -502,6 +727,7 @@ const chatState = {
|
||||
availableAccounts,
|
||||
contacts,
|
||||
selectedContact,
|
||||
searchContext,
|
||||
filteredContacts,
|
||||
searchQuery,
|
||||
showSearchAccountSwitcher,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="contacts-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
|
||||
<div class="flex-1 min-h-0 overflow-hidden p-4">
|
||||
<div class="h-full grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-4">
|
||||
<div class="bg-white border border-gray-200 rounded-lg flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
@@ -80,7 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
|
||||
<div class="contacts-export-panel bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-base font-medium text-gray-900">导出联系人</div>
|
||||
<div class="text-xs text-gray-500 mt-1">支持 JSON / CSV,默认包含头像链接</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen relative overflow-hidden flex items-center justify-center">
|
||||
<div class="decrypt-result-page min-h-screen relative overflow-hidden flex items-center justify-center">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
@@ -171,4 +171,4 @@ onMounted(() => {
|
||||
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center py-8">
|
||||
<div class="decrypt-page min-h-screen flex items-center justify-center py-8">
|
||||
|
||||
<div class="max-w-4xl mx-auto px-6 w-full">
|
||||
<!-- 步骤指示器 -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen relative overflow-hidden flex items-center">
|
||||
<div class="detection-result-page min-h-screen relative overflow-hidden flex items-center">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="edits-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
<!-- 左侧:会话列表(与聊天页统一风格) -->
|
||||
<div class="edits-sidebar border-r border-gray-200 flex flex-col">
|
||||
<!-- 搜索栏区域 -->
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
@@ -137,7 +137,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="flex-1 overflow-y-auto" style="background-color: #EDEDED">
|
||||
<div class="flex-1 overflow-y-auto" style="background-color: var(--app-shell-bg)">
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="itemsError" class="mx-5 mt-4 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-4 py-3 whitespace-pre-wrap">{{ itemsError }}</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<div class="landing-page min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="sns-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
<!-- 左侧朋友圈联系人 -->
|
||||
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]">
|
||||
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]" style="background-color: var(--app-shell-bg)">
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧朋友圈区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
|
||||
<div ref="timelineScrollEl" class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
|
||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||
<div class="relative w-full mb-12 -mt-4 bg-white">
|
||||
|
||||
@@ -303,14 +303,13 @@ const slides = computed(() => {
|
||||
return out
|
||||
})
|
||||
|
||||
const currentBg = computed(() => '#F3FFF8')
|
||||
const currentBg = '#F3FFF8'
|
||||
const deckTrackClass = computed(() => 'z-10')
|
||||
|
||||
const applyViewportBg = () => {
|
||||
if (!import.meta.client) return
|
||||
const bg = currentBg.value
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
document.body.style.backgroundColor = bg
|
||||
document.documentElement.style.backgroundColor = currentBg
|
||||
document.body.style.backgroundColor = currentBg
|
||||
}
|
||||
|
||||
const slideStyle = computed(() => (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const isDesktopShell = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const formatError = (error) => {
|
||||
if (!error) return ''
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: String(error.name || 'Error'),
|
||||
message: String(error.message || ''),
|
||||
stack: String(error.stack || '')
|
||||
}
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(error))
|
||||
} catch {}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const logDesktopDebug = (phase, details = {}) => {
|
||||
if (!isDesktopShell()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('nuxt-bootstrap', phase, {
|
||||
href: String(window.location?.href || ''),
|
||||
...details
|
||||
})
|
||||
} catch {}
|
||||
try {
|
||||
console.info(`[nuxt-bootstrap] ${phase}`, details)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
logDesktopDebug('plugin:setup')
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('error', (event) => {
|
||||
logDesktopDebug('window:error', {
|
||||
message: String(event?.message || ''),
|
||||
filename: String(event?.filename || ''),
|
||||
lineno: Number(event?.lineno || 0),
|
||||
colno: Number(event?.colno || 0),
|
||||
error: formatError(event?.error)
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
logDesktopDebug('window:unhandledrejection', {
|
||||
reason: formatError(event?.reason)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
nuxtApp.hook('app:created', () => {
|
||||
logDesktopDebug('app:created')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:beforeMount', () => {
|
||||
logDesktopDebug('app:beforeMount')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
logDesktopDebug('app:mounted')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
logDesktopDebug('page:start')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
logDesktopDebug('page:finish')
|
||||
})
|
||||
|
||||
nuxtApp.hook('vue:error', (error, _instance, info) => {
|
||||
logDesktopDebug('vue:error', {
|
||||
info: String(info || ''),
|
||||
error: formatError(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1774499781741" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
|
||||
<path d="M118.81813333 106.3936c87.27893333-26.2144 192.03413333 75.1616 358.46826667 346.9312 18.70506667 30.5152 34.7136 55.46666667 35.60106667 55.5008 0.88746667 0 17.74933333-26.4192 37.41013333-58.70933333 165.51253333-271.53066667 270.336-371.3024 359.35573333-342.08426667 56.55893333 18.56853333 80.41813333 73.18186667 80.21333334 183.63733333-0.4096 214.86933333-103.69706667 551.59466667-188.0064 612.89813334-69.4272 50.44906667-173.1584-13.07306667-269.85813334-165.30773334-9.59146667-15.1552-18.36373333-27.57973333-19.42186666-27.61386666-1.05813333 0-9.59146667 12.32213333-18.944 27.4432-50.96106667 82.39786667-113.4592 146.26133333-167.04853334 170.7008-26.04373333 11.8784-71.33866667 13.5168-90.45333333 3.24266666-52.08746667-27.98933333-110.72853333-149.504-156.16-323.72053333C7.3728 310.95466667 21.504 135.68 118.81813333 106.3936zM848.31573333 217.088c-55.26186667 42.93973333-126.49813333 138.58133333-230.8096 309.93066667l-49.2544 80.82773333 16.86186667 30.17386667c42.35946667 75.94666667 91.30666667 139.81013333 130.79893333 170.66666666 26.76053333 20.95786667 35.60106667 16.55466667 58.9824-29.4912 73.5232-144.55466667 136.192-440.7296 115.712-547.19146666-6.144-32.0512-15.80373333-35.46453333-42.2912-14.91626667zM143.73546667 207.9744c-19.72906667 19.49013333-14.60906667 145.8176 10.99093333 271.90613333 30.89066667 152.23466667 95.91466667 329.3184 124.5184 339.2512 27.81866667 9.65973333 104.31146667-77.824 164.38613333-188.0064l13.14133334-24.13226666-42.15466667-69.18826667c-112.98133333-185.344-186.64106667-284.3648-240.8448-323.72053333-16.65706667-12.0832-22.7328-13.312-30.03733333-6.10986667z" fill="#FF9908" p-id="7898"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
UI_THEME_DARK,
|
||||
UI_THEME_LIGHT,
|
||||
applyUiTheme,
|
||||
normalizeUiTheme,
|
||||
readUiTheme,
|
||||
writeUiTheme,
|
||||
} from '~/lib/ui-theme'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const theme = ref(UI_THEME_LIGHT)
|
||||
const initialized = ref(false)
|
||||
|
||||
const isDark = computed(() => theme.value === UI_THEME_DARK)
|
||||
|
||||
const set = (nextTheme) => {
|
||||
theme.value = normalizeUiTheme(nextTheme, UI_THEME_LIGHT)
|
||||
writeUiTheme(theme.value)
|
||||
applyUiTheme(theme.value)
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
if (initialized.value) {
|
||||
applyUiTheme(theme.value)
|
||||
return
|
||||
}
|
||||
initialized.value = true
|
||||
theme.value = readUiTheme(UI_THEME_LIGHT)
|
||||
applyUiTheme(theme.value)
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
set(isDark.value ? UI_THEME_LIGHT : UI_THEME_DARK)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
initialized,
|
||||
isDark,
|
||||
init,
|
||||
set,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
@@ -36,6 +36,7 @@ from .routers.wrapped import router as _wrapped_router
|
||||
from .request_logging import log_server_errors_middleware
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
from .routers.biz import router as _biz_router
|
||||
|
||||
app = FastAPI(
|
||||
title="微信数据库解密工具",
|
||||
@@ -96,6 +97,7 @@ app.include_router(_chat_media_router)
|
||||
app.include_router(_sns_router)
|
||||
app.include_router(_sns_export_router)
|
||||
app.include_router(_wrapped_router)
|
||||
app.include_router(_biz_router)
|
||||
|
||||
|
||||
class _SPAStaticFiles(StaticFiles):
|
||||
|
||||
@@ -3,6 +3,9 @@ from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ENV_DATA_DIR_KEY = "WECHAT_TOOL_DATA_DIR"
|
||||
ENV_OUTPUT_DIR_KEY = "WECHAT_TOOL_OUTPUT_DIR"
|
||||
|
||||
|
||||
def get_data_dir() -> Path:
|
||||
"""Base writable directory for all runtime output (logs, databases, key store).
|
||||
@@ -12,13 +15,16 @@ def get_data_dir() -> Path:
|
||||
- Dev defaults to the current working directory (repo root).
|
||||
"""
|
||||
|
||||
v = os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip()
|
||||
v = os.environ.get(ENV_DATA_DIR_KEY, "").strip()
|
||||
if v:
|
||||
return Path(v)
|
||||
return Path(v).expanduser()
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def get_output_dir() -> Path:
|
||||
v = os.environ.get(ENV_OUTPUT_DIR_KEY, "").strip()
|
||||
if v:
|
||||
return Path(v).expanduser()
|
||||
return get_data_dir() / "output"
|
||||
|
||||
|
||||
@@ -28,4 +34,3 @@ def get_output_databases_dir() -> Path:
|
||||
|
||||
def get_account_keys_path() -> Path:
|
||||
return get_output_dir() / "account_keys.json"
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Literal, Optional
|
||||
from typing import Any, Callable, Iterable, Literal, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
@@ -3386,6 +3386,7 @@ def _parse_message_for_export(
|
||||
resource_conn: Optional[sqlite3.Connection],
|
||||
resource_chat_id: Optional[int],
|
||||
sender_alias: str = "",
|
||||
resolve_display_name: Optional[Callable[[str], str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_text = row.raw_text or ""
|
||||
sender_username = str(row.sender_username or "").strip()
|
||||
@@ -3449,7 +3450,18 @@ def _parse_message_for_export(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
system_display_name_resolver = None
|
||||
if resolve_display_name is not None:
|
||||
def system_display_name_resolver(username: str, fallback_display_name: str) -> str:
|
||||
resolved = str(resolve_display_name(username) or "").strip()
|
||||
if resolved and resolved != username:
|
||||
return resolved
|
||||
fallback = str(fallback_display_name or "").strip()
|
||||
return fallback or resolved or username
|
||||
content_text = _parse_system_message_content(
|
||||
raw_text,
|
||||
resolve_display_name=system_display_name_resolver,
|
||||
)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -3923,6 +3935,7 @@ def _write_conversation_json(
|
||||
resource_conn=resource_conn,
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
resolve_display_name=resolve_display_name,
|
||||
)
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
@@ -4101,6 +4114,7 @@ def _write_conversation_txt(
|
||||
resource_conn=resource_conn,
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
resolve_display_name=resolve_display_name,
|
||||
)
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
@@ -4859,6 +4873,7 @@ def _write_conversation_html(
|
||||
resource_conn=resource_conn,
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias="",
|
||||
resolve_display_name=resolve_display_name,
|
||||
)
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
|
||||
@@ -7,7 +7,7 @@ import sqlite3
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -24,6 +24,17 @@ logger = get_logger(__name__)
|
||||
|
||||
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
|
||||
_DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1"
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
@@ -34,7 +45,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
for p in _OUTPUT_DATABASES_DIR.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"):
|
||||
accounts.append(p.name)
|
||||
|
||||
accounts.sort()
|
||||
@@ -49,7 +60,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
detail="No decrypted databases found. Please decrypt first.",
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
selected = str(account or "").strip() or accounts[0]
|
||||
if selected not in accounts:
|
||||
raise HTTPException(status_code=404, detail="Account not found.")
|
||||
base = _OUTPUT_DATABASES_DIR.resolve()
|
||||
candidate = (_OUTPUT_DATABASES_DIR / selected).resolve()
|
||||
|
||||
@@ -774,7 +787,112 @@ def _parse_location_message(text: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_system_message_content(raw_text: str) -> str:
|
||||
def _extract_chatroom_top_message_metadata(raw_text: str) -> dict[str, str]:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
|
||||
lower_text = text.lower()
|
||||
if "<mmchatroomtopmsg" in lower_text or "<sysmsg" in lower_text:
|
||||
chatroom_id = str(_extract_xml_tag_text(text, "chatroomname") or "").strip()
|
||||
operation = str(_extract_xml_tag_text(text, "op") or "").strip()
|
||||
operator_username = str(_extract_xml_tag_text(text, "username") or "").strip()
|
||||
operator_display_name = str(_extract_xml_tag_text(text, "nickname") or "").strip()
|
||||
if chatroom_id.endswith("@chatroom") and operation in {"1", "2"} and operator_username:
|
||||
return {
|
||||
"operation": operation,
|
||||
"operatorUsername": operator_username,
|
||||
"operatorDisplayName": operator_display_name,
|
||||
}
|
||||
|
||||
def _is_int_token(value: str) -> bool:
|
||||
candidate = str(value or "").strip()
|
||||
if not candidate:
|
||||
return False
|
||||
if candidate[0] in {"+", "-"}:
|
||||
candidate = candidate[1:]
|
||||
return candidate.isdigit()
|
||||
|
||||
normalized = re.sub(r"<!--\s*ChatRoomTopMsgRequest\s*-->", " ", text, flags=re.IGNORECASE)
|
||||
normalized = re.sub(r"<!--\s*ChatRoomTopMsgResponse\s*-->", " ", normalized, flags=re.IGNORECASE)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
if not normalized:
|
||||
return {}
|
||||
|
||||
parts = normalized.split(" ")
|
||||
has_markers = ("chatroomtopmsgrequest" in lower_text) or ("chatroomtopmsgresponse" in lower_text)
|
||||
if len(parts) < 5:
|
||||
return {}
|
||||
|
||||
chatroom_id = str(parts[0] or "").strip()
|
||||
operation = str(parts[1] or "").strip()
|
||||
if not chatroom_id.endswith("@chatroom"):
|
||||
return {}
|
||||
if operation not in {"1", "2"}:
|
||||
return {}
|
||||
|
||||
if not has_markers:
|
||||
if len(parts) < 6:
|
||||
return {}
|
||||
if not _is_int_token(parts[2]) or not _is_int_token(parts[3]) or not _is_int_token(parts[5]):
|
||||
return {}
|
||||
|
||||
operator_username = str(parts[4] or "").strip()
|
||||
if not operator_username:
|
||||
return {}
|
||||
|
||||
operator_display_name = ""
|
||||
if len(parts) >= 6 and _is_int_token(parts[5]):
|
||||
response_tokens = parts[6:]
|
||||
if len(response_tokens) >= 2 and _is_int_token(response_tokens[-1]):
|
||||
response_tokens = response_tokens[:-1]
|
||||
operator_display_name = " ".join(response_tokens).strip()
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"operatorUsername": operator_username,
|
||||
"operatorDisplayName": operator_display_name,
|
||||
}
|
||||
|
||||
|
||||
def _parse_chatroom_top_message(
|
||||
raw_text: str,
|
||||
resolve_display_name: Optional[Callable[[str, str], str]] = None,
|
||||
) -> str:
|
||||
meta = _extract_chatroom_top_message_metadata(raw_text)
|
||||
if not meta:
|
||||
return ""
|
||||
|
||||
operation = str(meta.get("operation") or "").strip()
|
||||
operator_username = str(meta.get("operatorUsername") or "").strip()
|
||||
operator_display_name = str(meta.get("operatorDisplayName") or "").strip()
|
||||
|
||||
if resolve_display_name is not None and operator_username:
|
||||
try:
|
||||
resolved = str(resolve_display_name(operator_username, operator_display_name) or "").strip()
|
||||
except Exception:
|
||||
resolved = ""
|
||||
if resolved:
|
||||
operator_display_name = resolved
|
||||
|
||||
if not operator_display_name:
|
||||
operator_display_name = operator_username or "有人"
|
||||
|
||||
action_map = {
|
||||
"1": "置顶了一条消息",
|
||||
"2": "移除了一条置顶消息",
|
||||
}
|
||||
action = action_map.get(operation)
|
||||
if not action:
|
||||
return ""
|
||||
|
||||
return f"{operator_display_name}{action}"
|
||||
|
||||
|
||||
def _parse_system_message_content(
|
||||
raw_text: str,
|
||||
resolve_display_name: Optional[Callable[[str, str], str]] = None,
|
||||
) -> str:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return "[系统消息]"
|
||||
@@ -788,12 +906,17 @@ def _parse_system_message_content(raw_text: str) -> str:
|
||||
if nested_content:
|
||||
candidate = nested_content
|
||||
|
||||
candidate = re.sub(r"<!--.*?-->", " ", candidate, flags=re.IGNORECASE | re.DOTALL)
|
||||
candidate = re.sub(r"<!\[CDATA\[", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(r"\]\]>", "", candidate)
|
||||
candidate = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", candidate)
|
||||
candidate = re.sub(r"\s+", " ", candidate).strip()
|
||||
return candidate
|
||||
|
||||
top_message_text = _parse_chatroom_top_message(text, resolve_display_name=resolve_display_name)
|
||||
if top_message_text:
|
||||
return top_message_text
|
||||
|
||||
if "revokemsg" in text.lower():
|
||||
replace_msg = _extract_xml_tag_text(text, "replacemsg")
|
||||
cleaned_replace_msg = _clean_system_text(replace_msg)
|
||||
@@ -1097,6 +1220,70 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"linkStyle": link_style,
|
||||
}
|
||||
|
||||
if app_type == 51:
|
||||
# 视频号分享(Finder / Channels)
|
||||
# 常见特征:
|
||||
# - title 是「当前版本不支持展示该内容,请升级至最新版本。」
|
||||
# - 真正标题在 <finderFeed><desc> 或其它 finder 节点里
|
||||
finder_feed = _extract_xml_tag_text(text, "finderFeed")
|
||||
finder_desc = (
|
||||
(_extract_xml_tag_text(finder_feed, "desc") if finder_feed else "")
|
||||
or _extract_xml_tag_text(text, "finderdesc")
|
||||
or des
|
||||
)
|
||||
finder_nickname = (
|
||||
_extract_xml_tag_text(text, "findernickname")
|
||||
or _extract_xml_tag_text(text, "finder_nickname")
|
||||
or (_extract_xml_tag_text(finder_feed, "nickname") if finder_feed else "")
|
||||
or (_extract_xml_tag_text(finder_feed, "findernickname") if finder_feed else "")
|
||||
)
|
||||
finder_username = (
|
||||
_extract_xml_tag_text(text, "finderusername")
|
||||
or _extract_xml_tag_text(text, "finder_username")
|
||||
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
|
||||
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
|
||||
)
|
||||
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(text, "thumburl")
|
||||
or _extract_xml_tag_or_attr(text, "cdnthumburl")
|
||||
or _extract_xml_tag_or_attr(text, "coverurl")
|
||||
or _extract_xml_tag_or_attr(text, "cover")
|
||||
or (_extract_xml_tag_or_attr(finder_feed, "thumbUrl") if finder_feed else "")
|
||||
or (_extract_xml_tag_or_attr(finder_feed, "thumburl") if finder_feed else "")
|
||||
or (_extract_xml_tag_or_attr(finder_feed, "coverUrl") if finder_feed else "")
|
||||
or (_extract_xml_tag_or_attr(finder_feed, "coverurl") if finder_feed else "")
|
||||
)
|
||||
|
||||
finder_url = url or _normalize_xml_url(
|
||||
(_extract_xml_tag_text(finder_feed, "url") if finder_feed else "")
|
||||
or (_extract_xml_tag_text(text, "playurl"))
|
||||
or (_extract_xml_tag_text(text, "dataurl"))
|
||||
)
|
||||
|
||||
display_title = str(title or "").strip()
|
||||
if (not display_title) or ("不支持" in display_title):
|
||||
display_title = str(finder_desc or "").strip()
|
||||
if not display_title:
|
||||
display_title = str(des or "").strip()
|
||||
display_title = display_title or "[视频号]"
|
||||
|
||||
summary_text = str(finder_desc or "").strip() or display_title
|
||||
from_display = str(finder_nickname or source_display_name or "").strip() or "视频号"
|
||||
from_u = str(finder_username or source_username or "").strip()
|
||||
|
||||
return {
|
||||
"renderType": "link",
|
||||
"content": summary_text,
|
||||
"title": display_title,
|
||||
"url": finder_url or "",
|
||||
"thumbUrl": thumb_url or "",
|
||||
"from": from_display,
|
||||
"fromUsername": from_u,
|
||||
"linkType": "finder",
|
||||
"linkStyle": "finder",
|
||||
}
|
||||
|
||||
if app_type in (33, 36):
|
||||
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32))
|
||||
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
|
||||
@@ -2321,4 +2508,5 @@ def _row_to_search_hit(
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
|
||||
}
|
||||
|
||||
@@ -41,6 +41,22 @@ class ColoredFormatter(logging.Formatter):
|
||||
return formatted
|
||||
|
||||
|
||||
def _can_use_logging_stream(stream) -> bool:
|
||||
try:
|
||||
if stream is None or getattr(stream, "closed", False):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
stream.write("")
|
||||
stream.flush()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class WeChatLogger:
|
||||
"""微信解密工具统一日志管理器"""
|
||||
|
||||
@@ -64,6 +80,12 @@ class WeChatLogger:
|
||||
if env_level:
|
||||
log_level = env_level
|
||||
|
||||
console_logging_env = str(os.environ.get("WECHAT_TOOL_ENABLE_CONSOLE_LOG", "") or "").strip().lower()
|
||||
console_logging_forced = console_logging_env in {"1", "true", "yes", "on"}
|
||||
console_logging_disabled = console_logging_env in {"0", "false", "no", "off"}
|
||||
|
||||
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
|
||||
|
||||
# 创建日志目录
|
||||
now = datetime.now()
|
||||
from .app_paths import get_output_dir
|
||||
@@ -73,10 +95,41 @@ class WeChatLogger:
|
||||
|
||||
# 设置日志文件名
|
||||
date_str = now.strftime("%d")
|
||||
self.log_file = log_dir / f"{date_str}_wechat_tool.log"
|
||||
desired_log_file = log_dir / f"{date_str}_wechat_tool.log"
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
wants_console_handler = _can_use_logging_stream(sys.stdout)
|
||||
if getattr(sys, "frozen", False) and not console_logging_forced:
|
||||
wants_console_handler = False
|
||||
if console_logging_disabled:
|
||||
wants_console_handler = False
|
||||
|
||||
if WeChatLogger._initialized:
|
||||
current_log_file = Path(getattr(self, "log_file", desired_log_file))
|
||||
has_expected_file_handler = False
|
||||
has_stream_handler = False
|
||||
for handler in root_logger.handlers:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
try:
|
||||
if Path(handler.baseFilename).resolve() == desired_log_file.resolve():
|
||||
has_expected_file_handler = True
|
||||
except Exception:
|
||||
if Path(handler.baseFilename) == desired_log_file:
|
||||
has_expected_file_handler = True
|
||||
elif isinstance(handler, logging.StreamHandler):
|
||||
has_stream_handler = True
|
||||
if (
|
||||
current_log_file == desired_log_file
|
||||
and root_logger.level == level
|
||||
and has_expected_file_handler
|
||||
and (has_stream_handler or not wants_console_handler)
|
||||
):
|
||||
self.log_file = desired_log_file
|
||||
return self.log_file
|
||||
|
||||
self.log_file = desired_log_file
|
||||
|
||||
# 清除现有的处理器
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
try:
|
||||
@@ -100,18 +153,20 @@ class WeChatLogger:
|
||||
# 文件处理器
|
||||
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(file_formatter)
|
||||
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
|
||||
file_handler.setLevel(level)
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.setLevel(level)
|
||||
console_handler = None
|
||||
if wants_console_handler:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
console_handler.setLevel(level)
|
||||
|
||||
# 配置根日志器
|
||||
root_logger.setLevel(level)
|
||||
root_logger.addHandler(file_handler)
|
||||
root_logger.addHandler(console_handler)
|
||||
if console_handler is not None:
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
|
||||
uvicorn_logger = logging.getLogger("uvicorn")
|
||||
@@ -158,7 +213,8 @@ class WeChatLogger:
|
||||
except Exception:
|
||||
pass
|
||||
fastapi_logger.addHandler(file_handler)
|
||||
fastapi_logger.addHandler(console_handler)
|
||||
if console_handler is not None:
|
||||
fastapi_logger.addHandler(console_handler)
|
||||
fastapi_logger.setLevel(level)
|
||||
|
||||
# 记录初始化信息
|
||||
|
||||
@@ -24,6 +24,17 @@ logger = get_logger(__name__)
|
||||
|
||||
# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录)
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
@@ -36,7 +47,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
for p in output_db_dir.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"):
|
||||
accounts.append(p.name)
|
||||
|
||||
accounts.sort()
|
||||
@@ -53,7 +64,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
detail="No decrypted databases found. Please decrypt first.",
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
selected = str(account or "").strip() or accounts[0]
|
||||
if selected not in accounts:
|
||||
raise HTTPException(status_code=404, detail="Account not found.")
|
||||
base = output_db_dir.resolve()
|
||||
candidate = (output_db_dir / selected).resolve()
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Dict, List
|
||||
import urllib
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..chat_helpers import _resolve_account_dir
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..logging_config import get_logger
|
||||
|
||||
try:
|
||||
import zstandard as zstd
|
||||
except Exception:
|
||||
zstd = None
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
def decompress_zstd_content(data: bytes, source_id: str, local_id: int) -> Optional[bytes]:
|
||||
"""Zstandard 解压逻辑"""
|
||||
if not data or not data.startswith(b'\x28\xb5\x2f\xfd'):
|
||||
return None
|
||||
try:
|
||||
if zstd:
|
||||
dctx = zstd.ZstdDecompressor()
|
||||
return dctx.decompress(data, max_output_size=10 * 1024 * 1024)
|
||||
except Exception as e:
|
||||
error_msg = f"❌ [解压失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
|
||||
print(error_msg)
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
|
||||
def extract_xml_from_db_content(content: Any, source_id: str, local_id: int) -> str:
|
||||
"""提取并解压数据库内容"""
|
||||
if not content:
|
||||
return ""
|
||||
|
||||
if isinstance(content, memoryview):
|
||||
content = content.tobytes()
|
||||
elif isinstance(content, str):
|
||||
content = content.encode('utf-8', errors='ignore')
|
||||
|
||||
if isinstance(content, bytes):
|
||||
decompressed = decompress_zstd_content(content, source_id, local_id)
|
||||
if decompressed:
|
||||
return decompressed.decode('utf-8', errors='ignore')
|
||||
|
||||
# 若不是 zstd 压缩或解压失败,尝试直接 decode
|
||||
try:
|
||||
return content.decode('utf-8', errors='ignore')
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def parse_wechat_xml_to_struct(xml_str: str, source_id: str, local_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""解析微信服务号 XML 到 Dict"""
|
||||
if not xml_str.strip():
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
def get_tag_text(element, path, default=""):
|
||||
node = element.find(path)
|
||||
return node.text if node is not None and node.text else default
|
||||
|
||||
main_cover = get_tag_text(root, ".//appmsg/thumburl")
|
||||
if not main_cover:
|
||||
main_cover = get_tag_text(root, ".//topnew/cover")
|
||||
|
||||
result = {
|
||||
"title": get_tag_text(root, ".//appmsg/title"),
|
||||
"des": get_tag_text(root, ".//appmsg/des"),
|
||||
"url": get_tag_text(root, ".//appmsg/url"),
|
||||
"cover": main_cover,
|
||||
"content_list": []
|
||||
}
|
||||
|
||||
items = root.findall(".//mmreader/category/item")
|
||||
for item in items:
|
||||
item_struct = {
|
||||
"title": get_tag_text(item, "title"),
|
||||
"url": get_tag_text(item, "url"),
|
||||
"cover": get_tag_text(item, "cover"),
|
||||
"summary": get_tag_text(item, "summary")
|
||||
}
|
||||
if item_struct["title"]:
|
||||
result["content_list"].append(item_struct)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
error_msg = f"❌ [解析XML失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
|
||||
print(error_msg)
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
|
||||
def parse_pay_xml(xml_str: str, local_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""解析微信支付 XML"""
|
||||
if not xml_str.strip():
|
||||
return None
|
||||
try:
|
||||
root = ET.fromstring(xml_str)
|
||||
|
||||
def get_text(path):
|
||||
node = root.find(path)
|
||||
return node.text if node is not None else ""
|
||||
|
||||
record = {
|
||||
"title": get_text(".//appmsg/title"),
|
||||
"description": get_text(".//appmsg/des"),
|
||||
"merchant_name": get_text(".//template_header/display_name"),
|
||||
"merchant_icon": get_text(".//template_header/icon_url"),
|
||||
"timestamp": int(get_text(".//pub_time") or 0),
|
||||
"formatted_time": ""
|
||||
}
|
||||
return record
|
||||
except Exception as e:
|
||||
error_msg = f"❌ [解析微信支付XML失败] 支付id: gh_3dfda90e39d6, local_id: {local_id} -> {e}"
|
||||
print(error_msg)
|
||||
logger.error(error_msg)
|
||||
return None
|
||||
|
||||
@router.get("/api/biz/proxy_image", summary="代理请求微信服务号图片")
|
||||
def proxy_biz_image(url: str):
|
||||
if not url:
|
||||
return Response(status_code=400)
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
content = response.read()
|
||||
content_type = response.headers.get('Content-Type', 'image/jpeg')
|
||||
return Response(content=content, media_type=content_type)
|
||||
except Exception as e:
|
||||
logger.error(f"[biz] 代理图片失败: {url} -> {e}")
|
||||
return Response(status_code=500)
|
||||
|
||||
# 接口 1:获取全部的服务号/公众号的信息
|
||||
@router.get("/api/biz/list", summary="获取全部服务号/公众号列表")
|
||||
def get_biz_account_list(account: Optional[str] = None):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
|
||||
biz_ids = set()
|
||||
biz_latest_time = {}
|
||||
|
||||
# 1. 遍历 biz_message_*.db
|
||||
for db_file in account_dir.glob("biz_message*.db"):
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("PRAGMA table_info(Name2Id)")
|
||||
cols = [row[1].lower() for row in cursor.fetchall()]
|
||||
user_col = "username" if "username" in cols else "user_name" if "user_name" in cols else ""
|
||||
|
||||
if user_col:
|
||||
rows = cursor.execute(f"SELECT {user_col} FROM Name2Id").fetchall()
|
||||
for r in rows:
|
||||
if r[0]:
|
||||
uname = r[0]
|
||||
biz_ids.add(uname)
|
||||
|
||||
# 顺便查询该号的最后一条消息时间
|
||||
md5_id = hashlib.md5(uname.encode('utf-8')).hexdigest().lower()
|
||||
table_name = f"Msg_{md5_id}"
|
||||
try:
|
||||
time_res = conn.execute(f"SELECT MAX(create_time) FROM {table_name}").fetchone()
|
||||
if time_res and time_res[0]:
|
||||
current_max = biz_latest_time.get(uname, 0)
|
||||
biz_latest_time[uname] = max(current_max, time_res[0])
|
||||
except Exception:
|
||||
pass
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 Name2Id 失败 {db_file}: {e}")
|
||||
|
||||
contact_db_path = account_dir / "contact.db"
|
||||
contact_info = {}
|
||||
if contact_db_path.exists() and biz_ids:
|
||||
try:
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
cursor = conn.cursor()
|
||||
placeholders = ",".join(["?"] * len(biz_ids))
|
||||
|
||||
# 先查 contact 表
|
||||
query_contact = f"SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN ({placeholders})"
|
||||
rows_contact = cursor.execute(query_contact, list(biz_ids)).fetchall()
|
||||
|
||||
for r in rows_contact:
|
||||
uname = r[0]
|
||||
name = r[1] or r[2] or r[3] or uname
|
||||
contact_info[uname] = {
|
||||
"username": uname,
|
||||
"name": name,
|
||||
"avatar": r[4],
|
||||
"type": 3 # 默认给个 3(未知)
|
||||
}
|
||||
|
||||
# 再查 biz_info 表获取类型
|
||||
try:
|
||||
query_biz = f"SELECT username, type FROM biz_info WHERE username IN ({placeholders})"
|
||||
rows_biz = cursor.execute(query_biz, list(biz_ids)).fetchall()
|
||||
for r in rows_biz:
|
||||
uname = r[0]
|
||||
biz_type = r[1]
|
||||
# 如果查到了且是 0, 1, 2,就更新进去,否则保留 3
|
||||
if uname in contact_info:
|
||||
if biz_type in (0, 1, 2):
|
||||
contact_info[uname]["type"] = biz_type
|
||||
else:
|
||||
contact_info[uname]["type"] = 3
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 biz_info 失败: {e}")
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"读取 contact.db 失败: {e}")
|
||||
|
||||
# 3. 组装结果(不在 contact_info 里的直接丢弃)
|
||||
result = []
|
||||
for uid in biz_ids:
|
||||
if uid in contact_info:
|
||||
info = contact_info[uid]
|
||||
info["last_time"] = biz_latest_time.get(uid, 0)
|
||||
if info["last_time"]:
|
||||
# 格式化日期给前端展示用
|
||||
info["formatted_last_time"] = time.strftime("%Y-%m-%d", time.localtime(info["last_time"]))
|
||||
else:
|
||||
info["formatted_last_time"] = ""
|
||||
result.append(info)
|
||||
|
||||
# 4. 按最后一条消息的时间降序排列
|
||||
result.sort(key=lambda x: x.get("last_time", 0), reverse=True)
|
||||
|
||||
return {"status": "success", "total": len(result), "data": result}
|
||||
|
||||
|
||||
# 接口 2:获取普通服务号/公众号的 json 消息 (已修复表名比对 bug)
|
||||
@router.get("/api/biz/messages", summary="获取指定服务号的消息")
|
||||
def get_biz_messages(username: str, account: Optional[str] = None, limit: int = 50, offset: int = 0):
|
||||
if username == "gh_3dfda90e39d6":
|
||||
raise HTTPException(status_code=400, detail="微信支付记录请请求 /api/biz/pay_records 接口")
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
|
||||
table_name = f"Msg_{md5_id}"
|
||||
|
||||
target_db = None
|
||||
for db_file in account_dir.glob("biz_message*.db"):
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
try:
|
||||
# 必须用 table_name.lower(),否则永远匹配不上
|
||||
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
|
||||
(table_name.lower(),)).fetchone()
|
||||
if res:
|
||||
target_db = db_file
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not target_db:
|
||||
return {"status": "success", "data": [], "message": f"未找到 {username} 的消息历史"}
|
||||
|
||||
# ... (后续数据库查询逻辑保持不变) ...
|
||||
messages = []
|
||||
try:
|
||||
conn = sqlite3.connect(str(target_db))
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = f"""
|
||||
SELECT local_id, create_time, message_content
|
||||
FROM [{table_name}]
|
||||
WHERE local_type != 1
|
||||
ORDER BY create_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = cursor.execute(query, (limit, offset)).fetchall()
|
||||
|
||||
for local_id, c_time, content in rows:
|
||||
raw_xml = extract_xml_from_db_content(content, username, local_id)
|
||||
if not raw_xml:
|
||||
continue
|
||||
|
||||
struct_data = parse_wechat_xml_to_struct(raw_xml, username, local_id)
|
||||
if struct_data:
|
||||
struct_data["local_id"] = local_id
|
||||
struct_data["create_time"] = c_time
|
||||
messages.append(struct_data)
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[biz] 数据库查询出错: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
return {"status": "success", "data": messages}
|
||||
|
||||
|
||||
# 接口 3:返回微信支付的 json 消息 (已修复表名比对 bug)
|
||||
@router.get("/api/biz/pay_records", summary="获取微信支付记录")
|
||||
def get_wechat_pay_records(account: Optional[str] = None, limit: int = 50, offset: int = 0):
|
||||
username = "gh_3dfda90e39d6"
|
||||
account_dir = _resolve_account_dir(account)
|
||||
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
|
||||
table_name = f"Msg_{md5_id}"
|
||||
|
||||
target_db = None
|
||||
for db_file in account_dir.glob("biz_message*.db"):
|
||||
conn = sqlite3.connect(str(db_file))
|
||||
try:
|
||||
# 必须用 table_name.lower()
|
||||
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
|
||||
(table_name.lower(),)).fetchone()
|
||||
if res:
|
||||
target_db = db_file
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not target_db:
|
||||
return {"status": "success", "data": [], "message": "未找到微信支付的消息历史"}
|
||||
|
||||
messages = []
|
||||
try:
|
||||
conn = sqlite3.connect(str(target_db))
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = f"""
|
||||
SELECT local_id, create_time, message_content
|
||||
FROM [{table_name}]
|
||||
WHERE local_type = 21474836529 OR local_type != 1
|
||||
ORDER BY create_time DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = cursor.execute(query, (limit, offset)).fetchall()
|
||||
|
||||
for local_id, c_time, content in rows:
|
||||
raw_xml = extract_xml_from_db_content(content, username, local_id)
|
||||
if not raw_xml:
|
||||
continue
|
||||
|
||||
parsed_data = parse_pay_xml(raw_xml, local_id)
|
||||
if parsed_data:
|
||||
parsed_data["local_id"] = local_id
|
||||
parsed_data["create_time"] = c_time
|
||||
if not parsed_data["timestamp"]:
|
||||
parsed_data["timestamp"] = c_time
|
||||
|
||||
parsed_data["formatted_time"] = time.strftime(
|
||||
"%Y-%m-%d %H:%M:%S", time.localtime(parsed_data["timestamp"])
|
||||
)
|
||||
messages.append(parsed_data)
|
||||
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[biz] 查询微信支付数据库出错: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
return {"status": "success", "data": messages}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,87 @@ logger = get_logger(__name__)
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
|
||||
def _build_uncached_media_response(data: bytes, media_type: str) -> Response:
|
||||
resp = Response(content=data, media_type=media_type)
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
return resp
|
||||
|
||||
|
||||
def _image_candidate_variant_rank(path: Path) -> int:
|
||||
stem = str(path.stem or "").lower()
|
||||
if stem.endswith(("_b", ".b")):
|
||||
return 0
|
||||
if stem.endswith(("_h", ".h")):
|
||||
return 1
|
||||
if stem.endswith(("_c", ".c")):
|
||||
return 3
|
||||
if stem.endswith(("_t", ".t")):
|
||||
return 4
|
||||
return 2
|
||||
|
||||
|
||||
def _image_candidate_stat(path: Optional[Path]) -> tuple[int, float]:
|
||||
if not path:
|
||||
return 0, 0.0
|
||||
try:
|
||||
st = path.stat()
|
||||
return int(st.st_size), float(st.st_mtime)
|
||||
except Exception:
|
||||
return 0, 0.0
|
||||
|
||||
|
||||
def _should_prefer_live_image_candidates(
|
||||
*,
|
||||
cached_path: Optional[Path],
|
||||
live_candidates: list[Path],
|
||||
) -> bool:
|
||||
if not live_candidates:
|
||||
return False
|
||||
if not cached_path:
|
||||
return True
|
||||
|
||||
best_live = live_candidates[0]
|
||||
live_rank = _image_candidate_variant_rank(best_live)
|
||||
if live_rank < 2:
|
||||
return True
|
||||
|
||||
cache_size, cache_mtime = _image_candidate_stat(cached_path)
|
||||
live_size, live_mtime = _image_candidate_stat(best_live)
|
||||
if live_rank == 2 and live_size > cache_size:
|
||||
return True
|
||||
if live_rank == 2 and live_size >= cache_size and live_mtime > cache_mtime:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _write_cached_chat_image(account_dir: Path, md5: str, data: bytes) -> None:
|
||||
md5_norm = str(md5 or "").strip().lower()
|
||||
if (not md5_norm) or (not data):
|
||||
return
|
||||
|
||||
ext = _detect_image_extension(data)
|
||||
out_path = _get_decrypted_resource_path(account_dir, md5_norm, ext)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for stale_ext in ("jpg", "png", "gif", "webp", "dat"):
|
||||
stale_path = _get_decrypted_resource_path(account_dir, md5_norm, stale_ext)
|
||||
if stale_path == out_path:
|
||||
continue
|
||||
try:
|
||||
if stale_path.exists():
|
||||
stale_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if out_path.exists() and out_path.read_bytes() == data:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
out_path.write_bytes(data)
|
||||
|
||||
|
||||
def _resolve_avatar_remote_url(*, account_dir: Path, username: str) -> str:
|
||||
u = str(username or "").strip()
|
||||
if not u:
|
||||
@@ -1311,20 +1392,26 @@ async def get_chat_image(
|
||||
if md5_from_msg:
|
||||
md5 = md5_from_msg
|
||||
|
||||
# md5 模式:优先从解密资源目录读取(更快)
|
||||
cached_path: Optional[Path] = None
|
||||
cached_data = b""
|
||||
cached_media_type = "application/octet-stream"
|
||||
|
||||
# md5 模式:优先检查解密资源目录;如果微信目录里已经有更高质量版本,会在后面自动升级。
|
||||
if md5:
|
||||
decrypted_path = _try_find_decrypted_resource(account_dir, str(md5).lower())
|
||||
if decrypted_path:
|
||||
data = decrypted_path.read_bytes()
|
||||
media_type = _detect_image_media_type(data[:32])
|
||||
if media_type != "application/octet-stream" and _is_probably_valid_image(data, media_type):
|
||||
return Response(content=data, media_type=media_type)
|
||||
cached_path = decrypted_path
|
||||
cached_data = data
|
||||
cached_media_type = media_type
|
||||
# Corrupted cached file (e.g. wrong ext / partial data): remove and regenerate from source.
|
||||
try:
|
||||
if decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
elif decrypted_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
|
||||
try:
|
||||
decrypted_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 回退:从微信数据目录实时定位并解密
|
||||
wxid_dir = _resolve_account_wxid_dir(account_dir)
|
||||
@@ -1414,11 +1501,36 @@ async def get_chat_image(
|
||||
break
|
||||
|
||||
if not p:
|
||||
if cached_path:
|
||||
return _build_uncached_media_response(cached_data, cached_media_type)
|
||||
raise HTTPException(status_code=404, detail="Image not found.")
|
||||
|
||||
candidates.extend(_iter_media_source_candidates(p))
|
||||
candidates = _order_media_candidates(candidates)
|
||||
|
||||
if cached_path:
|
||||
try:
|
||||
cached_key = str(cached_path.resolve())
|
||||
except Exception:
|
||||
cached_key = str(cached_path)
|
||||
|
||||
live_candidates: list[Path] = []
|
||||
seen_live: set[str] = set()
|
||||
for candidate in candidates:
|
||||
try:
|
||||
key = str(candidate.resolve())
|
||||
except Exception:
|
||||
key = str(candidate)
|
||||
if key == cached_key or key in seen_live:
|
||||
continue
|
||||
seen_live.add(key)
|
||||
live_candidates.append(candidate)
|
||||
|
||||
if _should_prefer_live_image_candidates(cached_path=cached_path, live_candidates=live_candidates):
|
||||
candidates = [*live_candidates, cached_path]
|
||||
else:
|
||||
candidates = [cached_path, *live_candidates]
|
||||
|
||||
logger.info(f"chat_image: md5={md5} file_id={file_id} candidates={len(candidates)} first={p}")
|
||||
|
||||
data = b""
|
||||
@@ -1443,19 +1555,14 @@ async def get_chat_image(
|
||||
# 仅在 md5 有效时缓存到 resource 目录;file_id 可能非常长,避免写入超长文件名
|
||||
if md5 and media_type.startswith("image/"):
|
||||
try:
|
||||
out_md5 = str(md5).lower()
|
||||
ext = _detect_image_extension(data)
|
||||
out_path = _get_decrypted_resource_path(account_dir, out_md5, ext)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not out_path.exists():
|
||||
out_path.write_bytes(data)
|
||||
_write_cached_chat_image(account_dir, str(md5), data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
f"chat_image: md5={md5} file_id={file_id} chosen={chosen} media_type={media_type} bytes={len(data)}"
|
||||
)
|
||||
return Response(content=data, media_type=media_type)
|
||||
return _build_uncached_media_response(data, media_type)
|
||||
|
||||
|
||||
@router.get("/api/chat/media/emoji", summary="获取表情消息资源")
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..app_paths import get_output_databases_dir
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..key_store import upsert_account_keys_in_store
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -79,6 +79,8 @@ async def decrypt_databases(request: DecryptRequest):
|
||||
"account_results": results.get("account_results", {}),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"解密API异常: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -126,44 +128,17 @@ async def decrypt_databases_stream(
|
||||
yield _sse({"type": "scanning", "message": "正在扫描数据库文件..."})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
account_name = "unknown_account"
|
||||
path_parts = storage_path.parts
|
||||
account_patterns = ["wxid_"]
|
||||
for part in path_parts:
|
||||
for pattern in account_patterns:
|
||||
if part.startswith(pattern):
|
||||
parts = part.split("_")
|
||||
if len(parts) >= 3:
|
||||
account_name = "_".join(parts[:-1])
|
||||
else:
|
||||
account_name = part
|
||||
break
|
||||
if account_name != "unknown_account":
|
||||
break
|
||||
|
||||
if account_name == "unknown_account":
|
||||
for part in reversed(path_parts):
|
||||
if part != "db_storage" and len(part) > 3:
|
||||
account_name = part
|
||||
break
|
||||
|
||||
databases: list[dict] = []
|
||||
for root, _dirs, files in os.walk(storage_path):
|
||||
if "db_storage" not in str(root):
|
||||
continue
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append({"path": db_path, "name": file_name, "account": account_name})
|
||||
|
||||
if not databases:
|
||||
yield _sse({"type": "error", "message": "未找到微信数据库文件!请检查 db_storage_path 是否正确"})
|
||||
scan_result = scan_account_databases_from_path(p)
|
||||
if scan_result["status"] == "error":
|
||||
payload = {"type": "error", "message": scan_result["message"]}
|
||||
detected_accounts = scan_result.get("detected_accounts") or []
|
||||
if detected_accounts:
|
||||
payload["detected_accounts"] = detected_accounts
|
||||
yield _sse(payload)
|
||||
return
|
||||
|
||||
account_databases = {account_name: databases}
|
||||
account_databases = scan_result.get("account_databases", {})
|
||||
account_sources = scan_result.get("account_sources", {})
|
||||
total_databases = sum(len(dbs) for dbs in account_databases.values())
|
||||
|
||||
yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"})
|
||||
@@ -193,12 +168,9 @@ async def decrypt_databases_stream(
|
||||
|
||||
# Save a hint for later UI (same as non-stream endpoint).
|
||||
try:
|
||||
source_db_storage_path = p
|
||||
wxid_dir = ""
|
||||
if storage_path.name.lower() == "db_storage":
|
||||
wxid_dir = str(storage_path.parent)
|
||||
else:
|
||||
wxid_dir = str(storage_path)
|
||||
source_info = account_sources.get(account, {})
|
||||
source_db_storage_path = str(source_info.get("db_storage_path") or p)
|
||||
wxid_dir = str(source_info.get("wxid_dir") or "")
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps({"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -26,25 +26,75 @@ _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
|
||||
_WCDB_API_DLL_SELECTED: Optional[Path] = None
|
||||
|
||||
|
||||
def _iter_runtime_wcdb_api_dll_paths() -> tuple[Path, ...]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add_anchor(anchor: str | Path | None) -> None:
|
||||
if not anchor:
|
||||
return
|
||||
try:
|
||||
base = Path(anchor).resolve()
|
||||
except Exception:
|
||||
base = Path(anchor)
|
||||
candidate = base / "native" / "wcdb_api.dll"
|
||||
key = str(candidate).replace("/", "\\").rstrip("\\").lower()
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
candidates.append(candidate)
|
||||
|
||||
add_anchor(os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip())
|
||||
add_anchor(Path.cwd())
|
||||
if getattr(sys, "frozen", False):
|
||||
add_anchor(Path(sys.executable).resolve().parent)
|
||||
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
|
||||
try:
|
||||
resolved = path.resolve(strict=False)
|
||||
except Exception:
|
||||
resolved = path
|
||||
|
||||
try:
|
||||
default_resolved = _DEFAULT_WCDB_API_DLL.resolve(strict=False)
|
||||
except Exception:
|
||||
default_resolved = _DEFAULT_WCDB_API_DLL
|
||||
|
||||
if resolved == default_resolved:
|
||||
return True
|
||||
|
||||
for candidate in _iter_runtime_wcdb_api_dll_paths():
|
||||
try:
|
||||
if resolved == candidate.resolve(strict=False):
|
||||
return True
|
||||
except Exception:
|
||||
if resolved == candidate:
|
||||
return True
|
||||
|
||||
parts = tuple(str(part).lower() for part in resolved.parts)
|
||||
allowed_suffixes = (
|
||||
("backend", "native", "wcdb_api.dll"),
|
||||
("wechat_decrypt_tool", "native", "wcdb_api.dll"),
|
||||
)
|
||||
return any(parts[-len(suffix) :] == suffix for suffix in allowed_suffixes)
|
||||
|
||||
|
||||
def _candidate_wcdb_api_dll_paths() -> list[Path]:
|
||||
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
|
||||
"""Return allowed locations for wcdb_api.dll."""
|
||||
cands: list[Path] = []
|
||||
|
||||
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
|
||||
if env:
|
||||
cands.append(Path(env))
|
||||
env_path = Path(env)
|
||||
if _is_project_wcdb_api_dll_path(env_path):
|
||||
cands.append(env_path)
|
||||
else:
|
||||
logger.warning("[wcdb] ignore external wcdb_api.dll override: %s", env_path)
|
||||
|
||||
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
except Exception:
|
||||
repo_root = Path.cwd()
|
||||
|
||||
for p in [
|
||||
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
|
||||
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
|
||||
_DEFAULT_WCDB_API_DLL,
|
||||
]:
|
||||
for p in (_DEFAULT_WCDB_API_DLL,):
|
||||
if p not in cands:
|
||||
cands.append(p)
|
||||
|
||||
|
||||
@@ -27,6 +27,169 @@ from .app_paths import get_output_databases_dir
|
||||
# SQLite文件头
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _normalize_account_name(name: str) -> str:
|
||||
value = str(name or "").strip()
|
||||
if not value:
|
||||
return "unknown_account"
|
||||
|
||||
if value.startswith("wxid_"):
|
||||
parts = value.split("_")
|
||||
if len(parts) >= 3:
|
||||
trimmed = "_".join(parts[:-1]).strip()
|
||||
if trimmed:
|
||||
return trimmed
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _derive_account_name_from_path(path: Path) -> str:
|
||||
try:
|
||||
target = path.resolve()
|
||||
except Exception:
|
||||
target = path
|
||||
|
||||
for part in target.parts:
|
||||
part_str = str(part or "").strip()
|
||||
if part_str.startswith("wxid_"):
|
||||
return _normalize_account_name(part_str)
|
||||
|
||||
for part in reversed(target.parts):
|
||||
part_str = str(part or "").strip()
|
||||
if not part_str or part_str.lower() == "db_storage" or len(part_str) <= 3:
|
||||
continue
|
||||
return _normalize_account_name(part_str)
|
||||
|
||||
return "unknown_account"
|
||||
|
||||
|
||||
def _resolve_db_storage_roots(storage_path: Path) -> list[Path]:
|
||||
try:
|
||||
target = storage_path.resolve()
|
||||
except Exception:
|
||||
target = storage_path
|
||||
|
||||
if not target.exists():
|
||||
return []
|
||||
|
||||
current = target if target.is_dir() else target.parent
|
||||
probe = current
|
||||
while True:
|
||||
if probe.name.lower() == "db_storage":
|
||||
return [probe]
|
||||
parent = probe.parent
|
||||
if parent == probe:
|
||||
break
|
||||
probe = parent
|
||||
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
for root, dirs, _files in os.walk(current):
|
||||
root_path = Path(root)
|
||||
if root_path.name.lower() != "db_storage":
|
||||
continue
|
||||
roots.append(root_path)
|
||||
dirs[:] = []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
uniq: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
key = str(root)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
uniq.append(root)
|
||||
uniq.sort(key=lambda p: str(p).lower())
|
||||
return uniq
|
||||
|
||||
|
||||
def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
storage_path = Path(str(db_storage_path or "").strip())
|
||||
if not storage_path.exists():
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": [],
|
||||
}
|
||||
|
||||
db_roots = _resolve_db_storage_roots(storage_path)
|
||||
if not db_roots:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "未找到微信数据库文件!请确保路径指向具体账号的 db_storage 目录。",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": [],
|
||||
}
|
||||
|
||||
detected_accounts = [
|
||||
{
|
||||
"account": _derive_account_name_from_path(root),
|
||||
"db_storage_path": str(root),
|
||||
"wxid_dir": str(root.parent),
|
||||
}
|
||||
for root in db_roots
|
||||
]
|
||||
|
||||
if len(db_roots) > 1:
|
||||
account_names = ", ".join(
|
||||
[str(item.get("account") or item.get("db_storage_path") or "").strip() for item in detected_accounts]
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"检测到多个账号目录,请选择具体账号的 db_storage 目录后再解密,"
|
||||
f"不要直接选择上级目录。当前检测到: {account_names}"
|
||||
),
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
db_root = db_roots[0]
|
||||
account_name = _derive_account_name_from_path(db_root)
|
||||
databases: list[dict] = []
|
||||
for root, _dirs, files in os.walk(db_root):
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append(
|
||||
{
|
||||
"path": db_path,
|
||||
"name": file_name,
|
||||
"account": account_name,
|
||||
}
|
||||
)
|
||||
|
||||
if not databases:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "未找到微信数据库文件!请检查 db_storage_path 是否正确",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "",
|
||||
"account_databases": {account_name: databases},
|
||||
"account_sources": {
|
||||
account_name: {
|
||||
"db_storage_path": str(db_root),
|
||||
"wxid_dir": str(db_root.parent),
|
||||
}
|
||||
},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
def setup_logging():
|
||||
"""设置日志配置 - 已弃用,使用统一的日志配置"""
|
||||
from .logging_config import setup_logging as unified_setup_logging
|
||||
@@ -259,75 +422,28 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
|
||||
# 查找数据库文件并按账号组织
|
||||
account_databases = {} # {account_name: [db_info, ...]}
|
||||
account_sources = {}
|
||||
detected_accounts = []
|
||||
|
||||
if db_storage_path:
|
||||
# 使用指定路径查找数据库
|
||||
storage_path = Path(db_storage_path)
|
||||
|
||||
if storage_path.exists():
|
||||
# 尝试从路径中提取账号名
|
||||
account_name = "unknown_account"
|
||||
path_parts = storage_path.parts
|
||||
|
||||
# 常见的微信账号格式模式
|
||||
account_patterns = ['wxid_']
|
||||
|
||||
for part in path_parts:
|
||||
# 检查是否匹配已知的账号格式
|
||||
for pattern in account_patterns:
|
||||
if part.startswith(pattern):
|
||||
# 提取主要部分,去掉后面的随机后缀
|
||||
# 例如:wxid_v4mbduwqtzpt22_1e7a -> wxid_v4mbduwqtzpt22
|
||||
parts = part.split('_')
|
||||
if len(parts) >= 3: # wxid_主要部分_随机后缀
|
||||
account_name = '_'.join(parts[:-1]) # 去掉最后一个随机部分
|
||||
else:
|
||||
account_name = part # 如果格式不符合预期,保留原名
|
||||
break
|
||||
if account_name != "unknown_account":
|
||||
break
|
||||
|
||||
# 如果没有匹配到已知格式,使用包含数据库的目录名
|
||||
if account_name == "unknown_account":
|
||||
# 查找包含db_storage的父目录作为账号名
|
||||
for part in reversed(path_parts):
|
||||
if part != "db_storage" and len(part) > 3:
|
||||
account_name = part
|
||||
break
|
||||
|
||||
databases = []
|
||||
# 使用递归查找,与自动检测逻辑一致
|
||||
for root, dirs, files in os.walk(storage_path):
|
||||
# 只处理db_storage目录下的数据库文件
|
||||
if "db_storage" not in str(root):
|
||||
continue
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
# 排除不需要解密的数据库
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append({
|
||||
'path': db_path,
|
||||
'name': file_name,
|
||||
'account': account_name
|
||||
})
|
||||
|
||||
if databases:
|
||||
account_databases[account_name] = databases
|
||||
logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件")
|
||||
else:
|
||||
scan_result = scan_account_databases_from_path(db_storage_path)
|
||||
detected_accounts = scan_result.get("detected_accounts", [])
|
||||
if scan_result["status"] == "error":
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
"message": scan_result["message"],
|
||||
"total_databases": 0,
|
||||
"successful_count": 0,
|
||||
"failed_count": 0,
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": [],
|
||||
"failed_files": []
|
||||
"failed_files": [],
|
||||
"detected_accounts": scan_result.get("detected_accounts", []),
|
||||
}
|
||||
account_databases = scan_result.get("account_databases", {})
|
||||
account_sources = scan_result.get("account_sources", {})
|
||||
for account_name, databases in account_databases.items():
|
||||
logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件")
|
||||
else:
|
||||
# 不再支持自动检测,要求用户提供具体的db_storage_path
|
||||
return {
|
||||
@@ -387,14 +503,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
logger.info(f"账号 {account_name} 输出目录: {account_output_dir}")
|
||||
|
||||
try:
|
||||
source_db_storage_path = str(db_storage_path or "")
|
||||
wxid_dir = ""
|
||||
if db_storage_path:
|
||||
sp = Path(db_storage_path)
|
||||
if sp.name.lower() == "db_storage":
|
||||
wxid_dir = str(sp.parent)
|
||||
else:
|
||||
wxid_dir = str(sp)
|
||||
source_info = account_sources.get(account_name, {})
|
||||
source_db_storage_path = str(source_info.get("db_storage_path") or db_storage_path or "")
|
||||
wxid_dir = str(source_info.get("wxid_dir") or "")
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -473,7 +584,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": processed_files,
|
||||
"failed_files": failed_files,
|
||||
"account_results": account_results # 新增:按账号的详细结果
|
||||
"account_results": account_results, # 新增:按账号的详细结果
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestChatMediaImageCacheUpgrade(unittest.TestCase):
|
||||
def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
local_type INTEGER,
|
||||
verify_flag INTEGER,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(account, "", "我", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "测试好友", "", 1, 0, "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_session_db(self, path: Path, *, username: str) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT,
|
||||
is_hidden INTEGER,
|
||||
sort_timestamp INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_source_info(self, account_dir: Path, *, wxid_dir: Path) -> None:
|
||||
payload = {
|
||||
"wxid_dir": str(wxid_dir),
|
||||
"db_storage_path": "",
|
||||
}
|
||||
(account_dir / "_source.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def _seed_cached_resource(self, account_dir: Path, *, md5: str, payload: bytes) -> Path:
|
||||
resource_dir = account_dir / "resource" / md5[:2]
|
||||
resource_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = resource_dir / f"{md5}.jpg"
|
||||
target.write_bytes(payload)
|
||||
return target
|
||||
|
||||
def _seed_live_variant(self, wxid_dir: Path, *, username: str, md5: str, suffix: str, payload: bytes) -> Path:
|
||||
chat_hash = hashlib.md5(username.encode("utf-8")).hexdigest()
|
||||
target = wxid_dir / "msg" / "attach" / chat_hash / "2026-03" / "Img" / f"{md5}{suffix}.dat"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(payload)
|
||||
return target
|
||||
|
||||
def _build_client(self):
|
||||
import wechat_decrypt_tool.logging_config as logging_config
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.media_helpers as media_helpers
|
||||
import wechat_decrypt_tool.routers.chat_media as chat_media
|
||||
|
||||
logging.shutdown()
|
||||
importlib.reload(logging_config)
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(media_helpers)
|
||||
importlib.reload(chat_media)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(chat_media.router)
|
||||
return TestClient(app)
|
||||
|
||||
def test_live_high_variant_replaces_stale_cached_thumb(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
account_dir = root / "output" / "databases" / account
|
||||
wxid_dir = root / "wxid_source"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
|
||||
|
||||
cached_thumb = b"\xff\xd8\xff\xd9"
|
||||
live_original = b"\xff\xd8\xff\xe0" + (b"\x00" * 48) + b"\xff\xd9"
|
||||
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_thumb)
|
||||
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_h", payload=live_original)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
client = None
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
client = self._build_client()
|
||||
resp = client.get(
|
||||
"/api/chat/media/image",
|
||||
params={"account": account, "md5": md5, "username": username},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content, live_original)
|
||||
self.assertEqual(resp.headers.get("cache-control"), "no-store")
|
||||
self.assertEqual(cache_path.read_bytes(), live_original)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_cached_original_is_not_downgraded_by_live_thumb(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
md5 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
account_dir = root / "output" / "databases" / account
|
||||
wxid_dir = root / "wxid_source"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
wxid_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._seed_contact_db(account_dir / "contact.db", account=account, username=username)
|
||||
self._seed_session_db(account_dir / "session.db", username=username)
|
||||
self._seed_source_info(account_dir, wxid_dir=wxid_dir)
|
||||
|
||||
cached_original = b"\xff\xd8\xff\xe0" + (b"\x11" * 64) + b"\xff\xd9"
|
||||
live_thumb = b"\xff\xd8\xff\xd9"
|
||||
cache_path = self._seed_cached_resource(account_dir, md5=md5, payload=cached_original)
|
||||
self._seed_live_variant(wxid_dir, username=username, md5=md5, suffix="_t", payload=live_thumb)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
client = None
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
client = self._build_client()
|
||||
resp = client.get(
|
||||
"/api/chat/media/image",
|
||||
params={"account": account, "md5": md5, "username": username},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.content, cached_original)
|
||||
self.assertEqual(resp.headers.get("cache-control"), "no-store")
|
||||
self.assertEqual(cache_path.read_bytes(), cached_original)
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,236 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatRealtimeName2IdSync(unittest.TestCase):
|
||||
def test_sync_repairs_name2id_even_without_new_messages(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
username = "wxid_friend"
|
||||
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
msg_db_path = account_dir / "message_0.db"
|
||||
|
||||
conn = sqlite3.connect(str(msg_db_path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE "{table_name}" (
|
||||
local_id INTEGER PRIMARY KEY,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB,
|
||||
packed_info_data BLOB
|
||||
)
|
||||
""".format(table_name=table_name)
|
||||
)
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", ("wxid_old",))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (5, ?, 1)", ("wxid_gap_tail",))
|
||||
conn.execute(
|
||||
f'INSERT INTO "{table_name}" '
|
||||
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(10, 10010, 1, 10, 3, 1710000010, "hello", None, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
live_rows = [
|
||||
{"rowid": 1, "user_name": "acc", "is_session": 1},
|
||||
{"rowid": 2, "user_name": "wxid_old", "is_session": 1},
|
||||
{"rowid": 3, "user_name": "wxid_missing_a", "is_session": 1},
|
||||
{"rowid": 4, "user_name": "wxid_missing_b", "is_session": 1},
|
||||
{"rowid": 5, "user_name": "wxid_gap_tail", "is_session": 1},
|
||||
]
|
||||
|
||||
def _fake_exec_query(_handle, *, kind, path, sql):
|
||||
self.assertEqual(kind, "message")
|
||||
self.assertTrue(str(path).endswith("message_0.db"))
|
||||
if "COUNT(1)" in sql:
|
||||
return [{"c": len(live_rows), "mx": 5}]
|
||||
if "ORDER BY rowid ASC" in sql:
|
||||
return list(live_rows)
|
||||
raise AssertionError(f"Unexpected SQL: {sql}")
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_db_storage_message_paths", return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db")),
|
||||
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
|
||||
patch.object(chat_router, "_wcdb_get_messages", return_value=[]),
|
||||
):
|
||||
result = chat_router._sync_chat_realtime_messages_for_table(
|
||||
account_dir=account_dir,
|
||||
rt_conn=_DummyConn(),
|
||||
username=username,
|
||||
msg_db_path=msg_db_path,
|
||||
table_name=table_name,
|
||||
max_scan=50,
|
||||
backfill_limit=0,
|
||||
)
|
||||
|
||||
self.assertEqual(result.get("inserted"), 0)
|
||||
|
||||
conn = sqlite3.connect(str(msg_db_path))
|
||||
try:
|
||||
rows = conn.execute("SELECT rowid, user_name FROM Name2Id ORDER BY rowid ASC").fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self.assertEqual(
|
||||
rows,
|
||||
[
|
||||
(1, "acc"),
|
||||
(2, "wxid_old"),
|
||||
(3, "wxid_missing_a"),
|
||||
(4, "wxid_missing_b"),
|
||||
(5, "wxid_gap_tail"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_sync_still_inserts_new_messages_when_name2id_is_up_to_date(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
username = "wxid_friend"
|
||||
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
msg_db_path = account_dir / "message_0.db"
|
||||
|
||||
conn = sqlite3.connect(str(msg_db_path))
|
||||
try:
|
||||
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE "{table_name}" (
|
||||
local_id INTEGER PRIMARY KEY,
|
||||
server_id INTEGER,
|
||||
local_type INTEGER,
|
||||
sort_seq INTEGER,
|
||||
real_sender_id INTEGER,
|
||||
create_time INTEGER,
|
||||
message_content TEXT,
|
||||
compress_content BLOB,
|
||||
packed_info_data BLOB
|
||||
)
|
||||
""".format(table_name=table_name)
|
||||
)
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
|
||||
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", (username,))
|
||||
conn.execute(
|
||||
f'INSERT INTO "{table_name}" '
|
||||
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(10, 10010, 1, 10, 2, 1710000010, "old", None, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
session_conn = sqlite3.connect(str(account_dir / "session.db"))
|
||||
try:
|
||||
session_conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT PRIMARY KEY,
|
||||
summary TEXT DEFAULT '',
|
||||
last_timestamp INTEGER DEFAULT 0,
|
||||
sort_timestamp INTEGER DEFAULT 0,
|
||||
last_msg_locald_id INTEGER DEFAULT 0,
|
||||
last_msg_type INTEGER DEFAULT 0,
|
||||
last_msg_sub_type INTEGER DEFAULT 0,
|
||||
last_msg_sender TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
session_conn.commit()
|
||||
finally:
|
||||
session_conn.close()
|
||||
|
||||
def _fake_exec_query(_handle, *, kind, path, sql):
|
||||
self.assertEqual(kind, "message")
|
||||
self.assertTrue(str(path).endswith("message_0.db"))
|
||||
if "COUNT(1)" in sql:
|
||||
return [{"c": 2, "mx": 2}]
|
||||
raise AssertionError(f"Unexpected SQL: {sql}")
|
||||
|
||||
live_messages = [
|
||||
{
|
||||
"local_id": 11,
|
||||
"server_id": 10011,
|
||||
"local_type": 1,
|
||||
"sort_seq": 11,
|
||||
"real_sender_id": 2,
|
||||
"create_time": 1710000011,
|
||||
"message_content": "new message",
|
||||
"compress_content": None,
|
||||
"sender_username": username,
|
||||
}
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
chat_router,
|
||||
"_resolve_db_storage_message_paths",
|
||||
return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db"),
|
||||
),
|
||||
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
|
||||
patch.object(chat_router, "_wcdb_get_messages", side_effect=[list(live_messages)]),
|
||||
patch.object(chat_router, "_best_effort_upsert_output_name2id_rows") as mock_upsert_name2id,
|
||||
):
|
||||
result = chat_router._sync_chat_realtime_messages_for_table(
|
||||
account_dir=account_dir,
|
||||
rt_conn=_DummyConn(),
|
||||
username=username,
|
||||
msg_db_path=msg_db_path,
|
||||
table_name=table_name,
|
||||
max_scan=50,
|
||||
backfill_limit=0,
|
||||
)
|
||||
|
||||
self.assertEqual(result.get("inserted"), 1)
|
||||
mock_upsert_name2id.assert_not_called()
|
||||
|
||||
conn = sqlite3.connect(str(msg_db_path))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f'SELECT local_id, server_id, real_sender_id, create_time, message_content FROM "{table_name}" ORDER BY local_id ASC'
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
self.assertEqual(
|
||||
rows,
|
||||
[
|
||||
(10, 10010, 2, 1710000010, "old"),
|
||||
(11, 10011, 2, 1710000011, "new message"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatRealtimeSystemMessageDisplayName(unittest.TestCase):
|
||||
def test_realtime_chatroom_top_message_prefers_remark_name(self):
|
||||
raw_text = (
|
||||
"17990148862@chatroom 2 3546361838777087323 0 "
|
||||
"wxid_k7zhjk9xvzsk22 21 A 69"
|
||||
)
|
||||
wcdb_rows = [
|
||||
{
|
||||
"localId": 1,
|
||||
"serverId": 123,
|
||||
"localType": 10000,
|
||||
"sortSeq": 1700000000000,
|
||||
"realSenderId": 0,
|
||||
"createTime": 1700000000,
|
||||
"messageContent": raw_text,
|
||||
"compressContent": None,
|
||||
"packedInfoData": None,
|
||||
"senderUsername": "",
|
||||
"isSent": False,
|
||||
}
|
||||
]
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn = _DummyConn()
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_messages", return_value=wcdb_rows),
|
||||
patch.object(
|
||||
chat_router,
|
||||
"_load_contact_rows",
|
||||
return_value={
|
||||
"wxid_k7zhjk9xvzsk22": {
|
||||
"remark": "周鑫",
|
||||
"nick_name": "A",
|
||||
"alias": "",
|
||||
}
|
||||
},
|
||||
),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
|
||||
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
|
||||
patch.object(chat_router, "_load_usernames_by_display_names", return_value={}),
|
||||
patch.object(chat_router, "_load_group_nickname_map", return_value={}),
|
||||
):
|
||||
resp = chat_router.list_chat_messages(
|
||||
_DummyRequest(),
|
||||
username="17990148862@chatroom",
|
||||
account="acc",
|
||||
limit=50,
|
||||
offset=0,
|
||||
order="asc",
|
||||
render_types=None,
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
messages = resp.get("messages") or []
|
||||
self.assertEqual(len(messages), 1)
|
||||
msg = messages[0]
|
||||
self.assertEqual(msg.get("renderType"), "system")
|
||||
self.assertEqual(msg.get("content"), "周鑫移除了一条置顶消息")
|
||||
self.assertNotIn("_rawText", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,6 +37,83 @@ class TestChatSystemMessageParsing(unittest.TestCase):
|
||||
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
|
||||
|
||||
def test_chatroom_top_message_uses_response_name_by_default(self):
|
||||
raw_text = (
|
||||
"<!-- ChatRoomTopMsgRequest --> 17990148862@chatroom 1 3546361838777087323 49 "
|
||||
"wxid_7iazcmpjn90k22 <!-- ChatRoomTopMsgResponse --> 21 新青年 68"
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
|
||||
|
||||
def test_chatroom_top_message_prefers_resolved_display_name(self):
|
||||
raw_text = (
|
||||
"<!-- ChatRoomTopMsgRequest --> 17990148862@chatroom 2 3546361838777087323 0 "
|
||||
"wxid_k7zhjk9xvzsk22 <!-- ChatRoomTopMsgResponse --> 21 A 69"
|
||||
)
|
||||
|
||||
def resolve_display_name(username: str, fallback: str) -> str:
|
||||
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
|
||||
self.assertEqual(fallback, "A")
|
||||
return "周鑫"
|
||||
|
||||
self.assertEqual(
|
||||
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
|
||||
"周鑫移除了一条置顶消息",
|
||||
)
|
||||
|
||||
def test_chatroom_top_message_without_comment_markers_still_parses(self):
|
||||
raw_text = "17990148862@chatroom 1 3546361838777087323 49 wxid_7iazcmpjn90k22 21 新青年 68"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
|
||||
|
||||
def test_chatroom_top_message_without_comment_markers_still_prefers_resolved_name(self):
|
||||
raw_text = "17990148862@chatroom 2 3546361838777087323 0 wxid_k7zhjk9xvzsk22 21 A 69"
|
||||
|
||||
def resolve_display_name(username: str, fallback: str) -> str:
|
||||
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
|
||||
self.assertEqual(fallback, "A")
|
||||
return "周鑫"
|
||||
|
||||
self.assertEqual(
|
||||
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
|
||||
"周鑫移除了一条置顶消息",
|
||||
)
|
||||
|
||||
def test_chatroom_top_message_xml_payload_still_parses(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="mmchatroomtopmsg"><mmchatroomtopmsg>'
|
||||
'<chatroomname><![CDATA[17990148862@chatroom]]></chatroomname>'
|
||||
'<op><![CDATA[1]]></op>'
|
||||
'<newmsgid><![CDATA[3546361838777087323]]></newmsgid>'
|
||||
'<msgtype><![CDATA[49]]></msgtype>'
|
||||
'<username><![CDATA[wxid_7iazcmpjn90k22]]></username>'
|
||||
'<id><![CDATA[21]]></id>'
|
||||
'<nickname><![CDATA[新青年]]></nickname>'
|
||||
'</mmchatroomtopmsg><chatroominfoversion><![CDATA[68]]></chatroominfoversion></sysmsg>'
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
|
||||
|
||||
def test_chatroom_top_message_xml_payload_prefers_resolved_name(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="mmchatroomtopmsg"><mmchatroomtopmsg>'
|
||||
'<chatroomname><![CDATA[17990148862@chatroom]]></chatroomname>'
|
||||
'<op><![CDATA[2]]></op>'
|
||||
'<newmsgid><![CDATA[3546361838777087323]]></newmsgid>'
|
||||
'<msgtype><![CDATA[0]]></msgtype>'
|
||||
'<username><![CDATA[wxid_k7zhjk9xvzsk22]]></username>'
|
||||
'<id><![CDATA[21]]></id>'
|
||||
'<nickname><![CDATA[A]]></nickname>'
|
||||
'</mmchatroomtopmsg><chatroominfoversion><![CDATA[69]]></chatroominfoversion></sysmsg>'
|
||||
)
|
||||
|
||||
def resolve_display_name(username: str, fallback: str) -> str:
|
||||
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
|
||||
self.assertEqual(fallback, "A")
|
||||
return "周鑫"
|
||||
|
||||
self.assertEqual(
|
||||
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
|
||||
"周鑫移除了一条置顶消息",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for handler in lg.handlers[:]:
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(handler)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestOutputDirOverride(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
self._prev_output_dir = os.environ.get("WECHAT_TOOL_OUTPUT_DIR")
|
||||
self._data_dir = TemporaryDirectory()
|
||||
self._output_dir = TemporaryDirectory()
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._data_dir.name
|
||||
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._output_dir.name
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.key_store as key_store
|
||||
import wechat_decrypt_tool.logging_config as logging_config
|
||||
import wechat_decrypt_tool.runtime_settings as runtime_settings
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(logging_config)
|
||||
importlib.reload(runtime_settings)
|
||||
importlib.reload(key_store)
|
||||
|
||||
self.app_paths = app_paths
|
||||
self.key_store = key_store
|
||||
self.logging_config = logging_config
|
||||
self.runtime_settings = runtime_settings
|
||||
|
||||
def tearDown(self) -> None:
|
||||
_close_logging_handlers()
|
||||
if self._prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
|
||||
|
||||
if self._prev_output_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_OUTPUT_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_OUTPUT_DIR"] = self._prev_output_dir
|
||||
|
||||
self._data_dir.cleanup()
|
||||
self._output_dir.cleanup()
|
||||
|
||||
def test_app_paths_prefers_output_dir_override(self) -> None:
|
||||
self.assertEqual(self.app_paths.get_output_dir(), Path(self._output_dir.name))
|
||||
self.assertEqual(
|
||||
self.app_paths.get_output_databases_dir(),
|
||||
Path(self._output_dir.name) / "databases",
|
||||
)
|
||||
|
||||
def test_logging_runtime_settings_and_key_store_use_output_override(self) -> None:
|
||||
log_file = self.logging_config.setup_logging()
|
||||
self.assertTrue(log_file.is_relative_to(Path(self._output_dir.name) / "logs"))
|
||||
|
||||
self.runtime_settings.write_backend_port_setting(12001)
|
||||
runtime_settings_path = Path(self._output_dir.name) / "runtime_settings.json"
|
||||
self.assertTrue(runtime_settings_path.exists())
|
||||
self.assertEqual(self.runtime_settings.read_backend_port_setting(), 12001)
|
||||
|
||||
self.key_store.upsert_account_keys_in_store("wxid_test", db_key="abc123")
|
||||
key_store_path = Path(self._output_dir.name) / "account_keys.json"
|
||||
self.assertTrue(key_store_path.exists())
|
||||
self.assertEqual(
|
||||
self.key_store.get_account_keys_from_store("wxid_test").get("db_key"),
|
||||
"abc123",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -118,6 +118,36 @@ class TestParseAppMessage(unittest.TestCase):
|
||||
self.assertEqual(parsed.get("linkType"), "official_article")
|
||||
self.assertEqual(parsed.get("linkStyle"), "cover")
|
||||
|
||||
def test_finder_type_51_uses_nested_desc_and_cover(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
|
||||
'<des></des>'
|
||||
'<type>51</type>'
|
||||
'<url></url>'
|
||||
'<finderFeed>'
|
||||
'<nickname><![CDATA[央视新闻]]></nickname>'
|
||||
'<username><![CDATA[finder_cctv_news]]></username>'
|
||||
'<desc><![CDATA[微信视频号全金融行业今公布发布]]></desc>'
|
||||
'<mediaList><media>'
|
||||
'<coverUrl><![CDATA[https://finder.video.qq.com/cover.jpg]]></coverUrl>'
|
||||
'<url><![CDATA[https://channels.weixin.qq.com/web/pages/feed?feedid=abc]]></url>'
|
||||
'</media></mediaList>'
|
||||
'</finderFeed>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("linkType"), "finder")
|
||||
self.assertEqual(parsed.get("title"), "微信视频号全金融行业今公布发布")
|
||||
self.assertEqual(parsed.get("content"), "微信视频号全金融行业今公布发布")
|
||||
self.assertEqual(parsed.get("from"), "央视新闻")
|
||||
self.assertEqual(parsed.get("fromUsername"), "finder_cctv_news")
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
|
||||
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
|
||||
|
||||
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool import wcdb_realtime
|
||||
|
||||
|
||||
class TestWcdbRealtimeDllPathSelection(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
wcdb_realtime._WCDB_API_DLL_SELECTED = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
wcdb_realtime._WCDB_API_DLL_SELECTED = None
|
||||
|
||||
def test_resolve_prefers_project_dll_over_weflow(self) -> None:
|
||||
weflow_dll = ROOT / "WeFlow" / "resources" / "wcdb_api.dll"
|
||||
self.assertTrue(weflow_dll.exists())
|
||||
self.assertTrue(wcdb_realtime._DEFAULT_WCDB_API_DLL.exists())
|
||||
|
||||
with patch.dict(os.environ, {"WECHAT_TOOL_WCDB_API_DLL_PATH": str(weflow_dll)}, clear=False):
|
||||
resolved = wcdb_realtime._resolve_wcdb_api_dll_path()
|
||||
|
||||
self.assertEqual(
|
||||
resolved.resolve(),
|
||||
wcdb_realtime._DEFAULT_WCDB_API_DLL.resolve(),
|
||||
)
|
||||
|
||||
def test_resolve_accepts_project_packaged_override(self) -> None:
|
||||
packaged_dll = ROOT / "desktop" / "resources" / "backend" / "native" / "wcdb_api.dll"
|
||||
self.assertTrue(packaged_dll.exists())
|
||||
|
||||
with patch.dict(os.environ, {"WECHAT_TOOL_WCDB_API_DLL_PATH": str(packaged_dll)}, clear=False):
|
||||
resolved = wcdb_realtime._resolve_wcdb_api_dll_path()
|
||||
|
||||
self.assertEqual(resolved.resolve(), packaged_dll.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user