Skip to content

Commit a38d99a

Browse files
committed
improvement(chat): 优化导出筛选与目录选择体验
1 parent 5901b8a commit a38d99a

7 files changed

Lines changed: 1199 additions & 351 deletions

File tree

desktop/src/main.cjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,25 @@ function registerWindowIpc() {
611611
return getCloseBehavior();
612612
}
613613
});
614+
615+
ipcMain.handle("dialog:chooseDirectory", async (_event, options) => {
616+
try {
617+
const result = await dialog.showOpenDialog({
618+
title: String(options?.title || "选择文件夹"),
619+
properties: ["openDirectory", "createDirectory"],
620+
});
621+
return {
622+
canceled: !!result?.canceled,
623+
filePaths: Array.isArray(result?.filePaths) ? result.filePaths : [],
624+
};
625+
} catch (err) {
626+
logMain(`[main] dialog:chooseDirectory failed: ${err?.message || err}`);
627+
return {
628+
canceled: true,
629+
filePaths: [],
630+
};
631+
}
632+
});
614633
}
615634

616635
async function main() {

desktop/src/preload.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,6 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
1111

1212
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
1313
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
14+
15+
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
1416
});

frontend/assets/css/tailwind.css

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -730,35 +730,39 @@
730730
}
731731

732732
.header-btn {
733-
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
733+
@apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md bg-white border border-gray-200 text-gray-700 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm;
734734
}
735735

736736
.header-btn:hover:not(:disabled) {
737-
@apply bg-gray-50 border-gray-300;
737+
@apply bg-gray-50 border-gray-300 shadow;
738738
}
739739

740740
.header-btn:active:not(:disabled) {
741-
@apply bg-gray-100;
741+
@apply bg-gray-100 scale-95;
742+
}
743+
744+
.header-btn svg {
745+
@apply w-3.5 h-3.5;
742746
}
743747

744748
.header-btn-icon {
745-
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-600 transition-all duration-200;
749+
@apply w-8 h-8 flex items-center justify-center rounded-lg bg-transparent border border-transparent text-gray-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
746750
}
747751

748752
.header-btn-icon:hover {
749-
@apply bg-gray-50 border-gray-300 text-gray-800;
753+
@apply bg-transparent border-transparent text-gray-800;
750754
}
751755

752756
.header-btn-icon-active {
753-
@apply bg-[#03C160]/10 border-[#03C160] text-[#03C160];
757+
@apply bg-transparent border-transparent text-[#03C160];
754758
}
755759

756760
.header-btn-icon-active:hover {
757-
@apply bg-[#03C160]/15;
761+
@apply bg-transparent;
758762
}
759763

760764
.message-filter-select {
761-
@apply text-xs px-2 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all disabled:opacity-50 disabled:cursor-not-allowed;
765+
@apply text-xs px-2 py-1.5 rounded-lg bg-transparent border-0 text-gray-700 focus:outline-none focus:ring-0 transition-all disabled:opacity-50 disabled:cursor-not-allowed;
762766
}
763767

764768
/* 搜索侧边栏样式 */

frontend/pages/chat/[[username]].vue

Lines changed: 656 additions & 277 deletions
Large diffs are not rendered by default.

src/wechat_decrypt_tool/chat_export_service.py

Lines changed: 87 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ def _safe_name(s: str, max_len: int = 80) -> str:
7474
return t
7575

7676

77+
def _resolve_export_output_dir(account_dir: Path, output_dir_raw: Any) -> Path:
78+
text = str(output_dir_raw or "").strip()
79+
if not text:
80+
default_dir = account_dir.parents[1] / "exports" / account_dir.name
81+
default_dir.mkdir(parents=True, exist_ok=True)
82+
return default_dir
83+
84+
out_dir = Path(text).expanduser()
85+
if not out_dir.is_absolute():
86+
raise ValueError("output_dir must be an absolute path.")
87+
88+
try:
89+
out_dir.mkdir(parents=True, exist_ok=True)
90+
except Exception as e:
91+
raise ValueError(f"Failed to prepare output_dir: {e}") from e
92+
93+
return out_dir.resolve()
94+
95+
7796
def _format_ts(ts: int) -> str:
7897
if not ts:
7998
return ""
@@ -99,43 +118,54 @@ def _normalize_render_type_key(value: Any) -> str:
99118
return lower
100119

101120

102-
def _render_types_to_local_types(render_types: set[str]) -> Optional[set[int]]:
103-
rt = {str(x or "").strip() for x in (render_types or set())}
104-
rt = {x for x in rt if x}
105-
if not rt:
121+
def _is_render_type_selected(render_type: Any, selected_render_types: Optional[set[str]]) -> bool:
122+
if selected_render_types is None:
123+
return True
124+
rt = _normalize_render_type_key(render_type) or "text"
125+
return rt in selected_render_types
126+
127+
128+
def _media_kinds_from_selected_types(selected_render_types: Optional[set[str]]) -> Optional[set[MediaKind]]:
129+
if selected_render_types is None:
106130
return None
107131

108-
out: set[int] = set()
109-
for k in rt:
110-
if k == "text":
111-
out.add(1)
112-
elif k == "image":
113-
out.add(3)
114-
elif k == "voice":
115-
out.add(34)
116-
elif k == "video":
117-
out.update({43, 62})
118-
elif k == "emoji":
119-
out.add(47)
120-
elif k == "voip":
121-
out.add(50)
122-
elif k == "system":
123-
out.update({10000, 266287972401})
124-
elif k == "quote":
125-
out.add(244813135921)
126-
out.add(49) # Some quote messages are embedded as appmsg (local_type=49).
127-
elif k in {"link", "file", "transfer", "redpacket"}:
128-
out.add(49)
129-
else:
130-
# Unknown type: cannot safely prefilter by local_type.
131-
return None
132+
out: set[MediaKind] = set()
133+
if "image" in selected_render_types:
134+
out.add("image")
135+
if "emoji" in selected_render_types:
136+
out.add("emoji")
137+
if "video" in selected_render_types:
138+
out.add("video")
139+
out.add("video_thumb")
140+
if "voice" in selected_render_types:
141+
out.add("voice")
142+
if "file" in selected_render_types:
143+
out.add("file")
132144
return out
133145

134146

135-
def _should_estimate_by_local_type(render_types: set[str]) -> bool:
136-
# Only estimate counts when every requested type maps 1:1 to local_type.
137-
# App messages (local_type=49) are heterogeneous and cannot be counted accurately without parsing.
138-
return not bool(render_types & {"link", "file", "transfer", "redpacket", "quote"})
147+
def _resolve_effective_media_kinds(
148+
*,
149+
include_media: bool,
150+
media_kinds: list[MediaKind],
151+
selected_render_types: Optional[set[str]],
152+
privacy_mode: bool,
153+
) -> tuple[bool, list[MediaKind]]:
154+
if privacy_mode or (not include_media):
155+
return False, []
156+
157+
kinds = [k for k in media_kinds if k in {"image", "emoji", "video", "video_thumb", "voice", "file"}]
158+
if not kinds:
159+
return False, []
160+
161+
selected_media_kinds = _media_kinds_from_selected_types(selected_render_types)
162+
if selected_media_kinds is not None:
163+
kinds = [k for k in kinds if k in selected_media_kinds]
164+
165+
kinds = list(dict.fromkeys(kinds))
166+
if not kinds:
167+
return False, []
168+
return True, kinds
139169

140170

141171
@dataclass
@@ -235,6 +265,7 @@ def create_job(
235265
include_media: bool,
236266
media_kinds: list[MediaKind],
237267
message_types: list[str],
268+
output_dir: Optional[str],
238269
allow_process_key_extract: bool,
239270
privacy_mode: bool,
240271
file_name: Optional[str],
@@ -257,6 +288,7 @@ def create_job(
257288
"includeMedia": bool(include_media),
258289
"mediaKinds": media_kinds,
259290
"messageTypes": list(dict.fromkeys([str(t or "").strip() for t in (message_types or []) if str(t or "").strip()])),
291+
"outputDir": str(output_dir or "").strip(),
260292
"allowProcessKeyExtract": bool(allow_process_key_extract),
261293
"privacyMode": bool(privacy_mode),
262294
"fileName": str(file_name or "").strip(),
@@ -313,10 +345,6 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
313345
if ks in {"image", "emoji", "video", "video_thumb", "voice", "file"}:
314346
media_kinds.append(ks) # type: ignore[arg-type]
315347

316-
if privacy_mode:
317-
include_media = False
318-
media_kinds = []
319-
320348
st = int(opts.get("startTime") or 0) or None
321349
et = int(opts.get("endTime") or 0) or None
322350

@@ -328,9 +356,15 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
328356
if want:
329357
want_types = want
330358

331-
local_types = _render_types_to_local_types(want_types) if want_types else None
332-
can_estimate = (want_types is None) or _should_estimate_by_local_type(want_types)
333-
estimate_local_types = local_types if (want_types and can_estimate) else None
359+
include_media, media_kinds = _resolve_effective_media_kinds(
360+
include_media=include_media,
361+
media_kinds=media_kinds,
362+
selected_render_types=want_types,
363+
privacy_mode=privacy_mode,
364+
)
365+
366+
local_types = None
367+
estimate_local_types = None
334368

335369
target_usernames = _resolve_export_targets(
336370
account_dir=account_dir,
@@ -342,8 +376,7 @@ def _run_job(self, job: ExportJob, account_dir: Path) -> None:
342376
if not target_usernames:
343377
raise ValueError("No target conversations to export.")
344378

345-
exports_root = account_dir.parents[1] / "exports" / account_dir.name
346-
exports_root.mkdir(parents=True, exist_ok=True)
379+
exports_root = _resolve_export_output_dir(account_dir, opts.get("outputDir"))
347380
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
348381

349382
base_name = str(opts.get("fileName") or "").strip()
@@ -456,16 +489,13 @@ def resolve_display_name(u: str) -> str:
456489
job.progress.current_conversation_messages_total = 0
457490

458491
try:
459-
if not can_estimate:
460-
estimated_total = 0
461-
else:
462-
estimated_total = _estimate_conversation_message_count(
463-
account_dir=account_dir,
464-
conv_username=conv_username,
465-
start_time=st,
466-
end_time=et,
467-
local_types=estimate_local_types,
468-
)
492+
estimated_total = _estimate_conversation_message_count(
493+
account_dir=account_dir,
494+
conv_username=conv_username,
495+
start_time=st,
496+
end_time=et,
497+
local_types=estimate_local_types,
498+
)
469499
except Exception:
470500
estimated_total = 0
471501

@@ -557,6 +587,8 @@ def resolve_display_name(u: str) -> str:
557587
zf.writestr(f"{conv_dir}/meta.json", json.dumps(meta, ensure_ascii=False, indent=2))
558588

559589
with self._lock:
590+
job.progress.current_conversation_messages_exported = int(exported_count)
591+
job.progress.current_conversation_messages_total = int(exported_count)
560592
job.progress.conversations_done += 1
561593

562594
manifest = {
@@ -1325,12 +1357,8 @@ def lookup_alias(username: str) -> str:
13251357
resource_chat_id=resource_chat_id,
13261358
sender_alias=sender_alias,
13271359
)
1328-
if want_types:
1329-
rt_key = _normalize_render_type_key(msg.get("renderType"))
1330-
if rt_key not in want_types:
1331-
if scanned % 500 == 0 and job.cancel_requested:
1332-
raise _JobCancelled()
1333-
continue
1360+
if not _is_render_type_selected(msg.get("renderType"), want_types):
1361+
continue
13341362

13351363
su = str(msg.get("senderUsername") or "").strip()
13361364
if privacy_mode:
@@ -1506,12 +1534,8 @@ def lookup_alias(username: str) -> str:
15061534
resource_chat_id=resource_chat_id,
15071535
sender_alias=sender_alias,
15081536
)
1509-
if want_types:
1510-
rt_key = _normalize_render_type_key(msg.get("renderType"))
1511-
if rt_key not in want_types:
1512-
if scanned % 500 == 0 and job.cancel_requested:
1513-
raise _JobCancelled()
1514-
continue
1537+
if not _is_render_type_selected(msg.get("renderType"), want_types):
1538+
continue
15151539

15161540
su = str(msg.get("senderUsername") or "").strip()
15171541
if privacy_mode:

src/wechat_decrypt_tool/routers/chat_export.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ class ChatExportCreateRequest(BaseModel):
2727
end_time: Optional[int] = Field(None, description="结束时间(Unix 秒,含)")
2828
include_hidden: bool = Field(False, description="是否包含隐藏会话(scope!=selected 时)")
2929
include_official: bool = Field(False, description="是否包含公众号/官方账号会话(scope!=selected 时)")
30-
include_media: bool = Field(True, description="是否打包离线媒体(图片/表情/视频/语音/文件)")
30+
include_media: bool = Field(True, description="是否允许打包离线媒体(最终仍受 message_types 与 privacy_mode 约束)")
3131
media_kinds: list[MediaKind] = Field(
3232
default_factory=lambda: ["image", "emoji", "video", "video_thumb", "voice", "file"],
33-
description="打包的媒体类型",
33+
description="允许打包的媒体类型(最终仍受 message_types 勾选约束)",
3434
)
3535
message_types: list[MessageType] = Field(
3636
default_factory=list,
37-
description="导出消息类型(renderType)过滤:为空=导出全部消息;可多选(如仅 voice / 仅 transfer / 仅 redPacket 等)",
37+
description="导出消息类型(renderType)过滤:为空=导出全部类型;不为空时,仅导出勾选类型",
3838
)
39+
output_dir: Optional[str] = Field(None, description="导出目录绝对路径(可选;不填时使用默认目录)")
3940
allow_process_key_extract: bool = Field(
4041
False,
4142
description="预留字段:本项目不从微信进程提取媒体密钥,请使用 wx_key 获取并保存/批量解密",
@@ -61,6 +62,7 @@ async def create_chat_export(req: ChatExportCreateRequest):
6162
include_media=req.include_media,
6263
media_kinds=req.media_kinds,
6364
message_types=req.message_types,
65+
output_dir=req.output_dir,
6466
allow_process_key_extract=req.allow_process_key_extract,
6567
privacy_mode=req.privacy_mode,
6668
file_name=req.file_name,

0 commit comments

Comments
 (0)