mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-10 15:08:31 +00:00
66
.github/workflows/dev-daily-fixed.yml
vendored
66
.github/workflows/dev-daily-fixed.yml
vendored
@@ -60,7 +60,23 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG" --jq '.id')"
|
||||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_DEV_TAG"
|
||||||
|
settled="false"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||||
|
settled="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$settled" != "true" ]; then
|
||||||
|
echo "Failed to settle release state after create:"
|
||||||
|
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
dev-mac-arm64:
|
dev-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -81,6 +97,22 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
- name: Set dev version
|
- name: Set dev version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
@@ -270,21 +302,25 @@ jobs:
|
|||||||
- name: Update fixed dev release notes
|
- name: Update fixed dev release notes
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TAG="$FIXED_DEV_TAG"
|
TAG="${FIXED_DEV_TAG:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "FIXED_DEV_TAG is empty, abort."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
REPO="$GITHUB_REPOSITORY"
|
REPO="$GITHUB_REPOSITORY"
|
||||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
echo "Using release tag: $TAG"
|
||||||
|
|
||||||
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG not found, skip notes update."
|
echo "Release $TAG not found, skip notes update."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||||
|
|
||||||
pick_asset() {
|
pick_asset() {
|
||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
@@ -350,4 +386,22 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_release_notes
|
update_release_notes
|
||||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||||
|
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||||
|
settled="false"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||||
|
settled="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$settled" != "true" ]; then
|
||||||
|
echo "Failed to settle release state after notes update:"
|
||||||
|
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
|
|||||||
65
.github/workflows/preview-nightly-main.yml
vendored
65
.github/workflows/preview-nightly-main.yml
vendored
@@ -86,7 +86,23 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease --target "$TARGET_BRANCH"
|
||||||
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
RELEASE_REST_ID="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG" --jq '.id')"
|
||||||
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -f draft=false -f prerelease=true >/dev/null
|
RELEASE_ENDPOINT="repos/$GITHUB_REPOSITORY/releases/tags/$FIXED_PREVIEW_TAG"
|
||||||
|
settled="false"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||||
|
settled="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$settled" != "true" ]; then
|
||||||
|
echo "Failed to settle release state after create:"
|
||||||
|
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
preview-mac-arm64:
|
preview-mac-arm64:
|
||||||
needs: prepare
|
needs: prepare
|
||||||
@@ -108,6 +124,22 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
- name: Set preview version
|
- name: Set preview version
|
||||||
shell: bash
|
shell: bash
|
||||||
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
@@ -315,17 +347,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
TAG="$FIXED_PREVIEW_TAG"
|
TAG="${FIXED_PREVIEW_TAG:-}"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
echo "FIXED_PREVIEW_TAG is empty, abort."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||||
REPO="$GITHUB_REPOSITORY"
|
REPO="$GITHUB_REPOSITORY"
|
||||||
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
echo "Using release tag: $TAG"
|
||||||
|
|
||||||
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
if ! gh api "repos/$REPO/releases/tags/$TAG" >/dev/null 2>&1; then
|
||||||
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
ASSETS_JSON="$(gh api "repos/$REPO/releases/tags/$TAG")"
|
||||||
|
|
||||||
pick_asset() {
|
pick_asset() {
|
||||||
local pattern="$1"
|
local pattern="$1"
|
||||||
@@ -392,4 +429,22 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_release_notes
|
update_release_notes
|
||||||
gh release view "$TAG" --repo "$REPO" --json isDraft,isPrerelease,url
|
RELEASE_REST_ID="$(gh api "repos/$REPO/releases/tags/$TAG" --jq '.id')"
|
||||||
|
RELEASE_ENDPOINT="repos/$REPO/releases/tags/$TAG"
|
||||||
|
settled="false"
|
||||||
|
for i in 1 2 3 4 5; do
|
||||||
|
gh api --method PATCH "repos/$REPO/releases/$RELEASE_REST_ID" -F draft=false -F prerelease=true >/dev/null 2>&1 || true
|
||||||
|
DRAFT_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.draft' 2>/dev/null || echo true)"
|
||||||
|
PRERELEASE_STATE="$(gh api "$RELEASE_ENDPOINT" --jq '.prerelease' 2>/dev/null || echo false)"
|
||||||
|
if [ "$DRAFT_STATE" = "false" ] && [ "$PRERELEASE_STATE" = "true" ]; then
|
||||||
|
settled="true"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "$settled" != "true" ]; then
|
||||||
|
echo "Failed to settle release state after notes update:"
|
||||||
|
gh api "$RELEASE_ENDPOINT" --jq '{draft: .draft, prerelease: .prerelease, url: .html_url}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh api "repos/$REPO/releases/tags/$TAG" --jq '{isDraft: .draft, isPrerelease: .prerelease, url: .html_url}'
|
||||||
|
|||||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -31,6 +31,22 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
|
- name: Ensure mac key helpers are executable
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for file in \
|
||||||
|
resources/key/macos/universal/xkey_helper \
|
||||||
|
resources/key/macos/universal/image_scan_helper \
|
||||||
|
resources/key/macos/universal/xkey_helper_macos \
|
||||||
|
resources/key/macos/universal/libwx_key.dylib
|
||||||
|
do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
ls -l "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
- name: Sync version with tag
|
- name: Sync version with tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -1,33 +1,31 @@
|
|||||||
# WeFlow
|
# WeFlow
|
||||||
|
|
||||||
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告
|
WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析与导出工具。它可以实时获取你的微信聊天记录并将其导出,还可以根据你的聊天记录为你生成独一无二的分析报告。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="app.png" alt="WeFlow" width="90%">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
|
<a href="https://t.me/weflow_cc">
|
||||||
|
<img src="https://img.shields.io/badge/Telegram-频道-38BDF8?style=flat-square&logo=telegram&logoColor=white&labelColor=111827" alt="Telegram Channel">
|
||||||
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/stargazers">
|
<a href="https://github.com/hicccc77/WeFlow/stargazers">
|
||||||
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square" alt="Stargazers">
|
<img src="https://img.shields.io/github/stars/hicccc77/WeFlow?style=flat-square&label=Stars&labelColor=111827&color=22D3EE" alt="Stargazers">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
<a href="https://github.com/hicccc77/WeFlow/network/members">
|
||||||
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square" alt="Forks">
|
<img src="https://img.shields.io/github/forks/hicccc77/WeFlow?style=flat-square&label=Forks&labelColor=111827&color=A78BFA" alt="Forks">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square&label=Issues&labelColor=111827&color=F59E0B" alt="Issues">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/hicccc77/WeFlow/releases">
|
<a href="https://github.com/hicccc77/WeFlow/releases">
|
||||||
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" alt="Downloads" />
|
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square&label=Downloads&labelColor=111827&color=22C55E" alt="Downloads" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://t.me/weflow_cc">
|
<a href="https://www.star-history.com/hicccc77/weflow">
|
||||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
<img alt="Star History Rank" src="https://api.star-history.com/badge?repo=hicccc77/WeFlow&theme=dark" height="20" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="app.png" alt="WeFlow 应用预览" width="90%">
|
||||||
|
</p>
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
@@ -47,14 +45,12 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
|
|
||||||
## 支持平台与设备
|
## 支持平台与设备
|
||||||
|
|
||||||
|
|
||||||
| 平台 | 设备/架构 | 安装包 |
|
| 平台 | 设备/架构 | 安装包 |
|
||||||
|------|----------|--------|
|
|------|----------|--------|
|
||||||
| Windows | Windows10+、x64(amd64) | `.exe` |
|
| Windows | Windows10+、x64(amd64) | `.exe` |
|
||||||
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
| macOS | Apple Silicon(M 系列,arm64) | `.dmg` |
|
||||||
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
@@ -93,7 +89,6 @@ WeFlow 提供本地 HTTP API 服务,支持通过接口查询消息数据,可
|
|||||||
|
|
||||||
完整接口文档:[点击查看](docs/HTTP-API.md)
|
完整接口文档:[点击查看](docs/HTTP-API.md)
|
||||||
|
|
||||||
|
|
||||||
## 面向开发者
|
## 面向开发者
|
||||||
|
|
||||||
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
如果你想从源码构建或为项目贡献代码,请遵循以下步骤:
|
||||||
@@ -108,9 +103,24 @@ npm install
|
|||||||
|
|
||||||
# 3. 运行应用(开发模式)
|
# 3. 运行应用(开发模式)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 构建状态
|
||||||
|
|
||||||
|
用于开发者排查发布链路,普通用户可忽略:
|
||||||
|
|
||||||
|
<p align="left">
|
||||||
|
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/release.yml">
|
||||||
|
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/release.yml?branch=main&label=Release&style=flat&labelColor=111827&color=22C55E" alt="Release Workflow">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/preview-nightly-main.yml">
|
||||||
|
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/preview-nightly-main.yml?branch=main&label=Preview%20Nightly&style=flat&labelColor=111827&color=F59E0B" alt="Preview Nightly Workflow">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/hicccc77/WeFlow/actions/workflows/dev-daily-fixed.yml">
|
||||||
|
<img src="https://img.shields.io/github/actions/workflow/status/hicccc77/WeFlow/dev-daily-fixed.yml?branch=dev&label=Dev%20Daily&style=flat&labelColor=111827&color=A78BFA" alt="Dev Daily Workflow">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||||
@@ -120,10 +130,8 @@ npm run dev
|
|||||||
|
|
||||||
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
如果 WeFlow 确实帮到了你,可以考虑请我们喝杯咖啡:
|
||||||
|
|
||||||
|
|
||||||
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
> TRC20 **Address:** `TZCtAw8CaeARWZBfvjidCnTcfnAtf6nvS6`
|
||||||
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
<a href="https://www.star-history.com/#hicccc77/WeFlow&type=date&legend=top-left">
|
||||||
|
|||||||
@@ -3455,12 +3455,38 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve({ timedOut: true, error: `timeout(${timeoutMs}ms)` })
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
task()
|
||||||
|
.then((value) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve({ timedOut: false, value })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve({ timedOut: false, error: String(error) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 初始化配置服务
|
// 初始化配置服务
|
||||||
updateSplashProgress(5, '正在加载配置...')
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
applyAutoUpdateChannel('startup')
|
applyAutoUpdateChannel('startup')
|
||||||
syncLaunchAtStartupPreference()
|
syncLaunchAtStartupPreference()
|
||||||
|
const onboardingDone = configService.get('onboardingDone') === true
|
||||||
|
shouldShowMain = onboardingDone
|
||||||
|
|
||||||
// 将用户主题配置推送给 Splash 窗口
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
@@ -3473,7 +3499,7 @@ app.whenReady().then(async () => {
|
|||||||
await delay(200)
|
await delay(200)
|
||||||
|
|
||||||
// 设置资源路径
|
// 设置资源路径
|
||||||
updateSplashProgress(10, '正在初始化...')
|
updateSplashProgress(12, '正在初始化...')
|
||||||
const candidateResources = app.isPackaged
|
const candidateResources = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
@@ -3483,13 +3509,13 @@ app.whenReady().then(async () => {
|
|||||||
await delay(200)
|
await delay(200)
|
||||||
|
|
||||||
// 初始化数据库服务
|
// 初始化数据库服务
|
||||||
updateSplashProgress(18, '正在初始化...')
|
updateSplashProgress(20, '正在初始化...')
|
||||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||||
await delay(200)
|
await delay(200)
|
||||||
|
|
||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
updateSplashProgress(25, '正在初始化...')
|
updateSplashProgress(28, '正在初始化...')
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
chatService.addDbMonitorListener((type, json) => {
|
chatService.addDbMonitorListener((type, json) => {
|
||||||
messagePushService.handleDbMonitorChange(type, json)
|
messagePushService.handleDbMonitorChange(type, json)
|
||||||
@@ -3499,12 +3525,54 @@ app.whenReady().then(async () => {
|
|||||||
insightService.start()
|
insightService.start()
|
||||||
await delay(200)
|
await delay(200)
|
||||||
|
|
||||||
// 检查配置状态
|
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
|
||||||
const onboardingDone = configService.get('onboardingDone')
|
if (onboardingDone) {
|
||||||
shouldShowMain = onboardingDone === true
|
updateSplashProgress(34, '正在连接数据库...')
|
||||||
|
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
|
||||||
|
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
const reason = connectWarmup.timedOut
|
||||||
|
? connectWarmup.error
|
||||||
|
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
|
||||||
|
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
|
||||||
|
updateSplashProgress(68, '数据库预热已跳过')
|
||||||
|
} else {
|
||||||
|
const preloadUsernames = new Set<string>()
|
||||||
|
|
||||||
|
updateSplashProgress(44, '正在预加载会话...')
|
||||||
|
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
|
||||||
|
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
|
||||||
|
for (const session of sessionsWarmup.value.sessions) {
|
||||||
|
const username = String((session as any)?.username || '').trim()
|
||||||
|
if (username) preloadUsernames.add(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSplashProgress(56, '正在预加载联系人...')
|
||||||
|
const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000)
|
||||||
|
if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) {
|
||||||
|
for (const contact of contactsWarmup.value.contacts) {
|
||||||
|
const username = String((contact as any)?.username || '').trim()
|
||||||
|
if (username) preloadUsernames.add(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSplashProgress(63, '正在缓存联系人头像...')
|
||||||
|
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
|
||||||
|
if (avatarWarmupUsernames.length > 0) {
|
||||||
|
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSplashProgress(68, '正在初始化消息库索引...')
|
||||||
|
await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateSplashProgress(68, '首次启动准备中...')
|
||||||
|
}
|
||||||
|
|
||||||
// 创建主窗口(不显示,由启动流程统一控制)
|
// 创建主窗口(不显示,由启动流程统一控制)
|
||||||
updateSplashProgress(30, '正在加载界面...')
|
updateSplashProgress(70, '正在准备主窗口...')
|
||||||
mainWindow = createWindow({ autoShow: false })
|
mainWindow = createWindow({ autoShow: false })
|
||||||
|
|
||||||
let iconName = 'icon.ico';
|
let iconName = 'icon.ico';
|
||||||
@@ -3576,7 +3644,7 @@ app.whenReady().then(async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||||
updateSplashProgress(30, '正在加载界面...', true)
|
updateSplashProgress(70, '正在准备主窗口...', true)
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
if (mainWindowReady) {
|
if (mainWindowReady) {
|
||||||
resolve()
|
resolve()
|
||||||
|
|||||||
@@ -323,6 +323,8 @@ class ChatService {
|
|||||||
private contactLabelNameMapCacheAt = 0
|
private contactLabelNameMapCacheAt = 0
|
||||||
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||||
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||||
|
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
|
||||||
|
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
|
||||||
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
|
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
|
||||||
private readonly slowGetContactsLogThresholdMs = 1200
|
private readonly slowGetContactsLogThresholdMs = 1200
|
||||||
|
|
||||||
@@ -513,6 +515,43 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async warmupMessageDbSnapshot(): Promise<{ success: boolean; messageDbCount?: number; mediaDbCount?: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const connectResult = await this.ensureConnected()
|
||||||
|
if (!connectResult.success) {
|
||||||
|
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const [messageSnapshot, mediaResult] = await Promise.all([
|
||||||
|
this.getMessageDbCountSnapshot(true),
|
||||||
|
wcdbService.listMediaDbs()
|
||||||
|
])
|
||||||
|
|
||||||
|
let messageDbCount = 0
|
||||||
|
if (messageSnapshot.success && Array.isArray(messageSnapshot.dbPaths)) {
|
||||||
|
messageDbCount = messageSnapshot.dbPaths.length
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaDbCount = 0
|
||||||
|
if (mediaResult.success && Array.isArray(mediaResult.data)) {
|
||||||
|
this.mediaDbsCache = [...mediaResult.data]
|
||||||
|
this.mediaDbsCacheTime = Date.now()
|
||||||
|
mediaDbCount = mediaResult.data.length
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!messageSnapshot.success && !mediaResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: messageSnapshot.error || mediaResult.error || '初始化消息库索引失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, messageDbCount, mediaDbCount }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
|
||||||
if (this.connected && wcdbService.isReady()) {
|
if (this.connected && wcdbService.isReady()) {
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -1362,8 +1401,50 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getContactsCacheScope(): string {
|
||||||
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
|
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
return `${dbPath}::${myWxid}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneContacts(contacts: ContactInfo[]): ContactInfo[] {
|
||||||
|
return (contacts || []).map((contact) => ({
|
||||||
|
...contact,
|
||||||
|
labels: Array.isArray(contact.labels) ? [...contact.labels] : contact.labels
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContactsFromMemoryCache(mode: 'lite' | 'full', scope: string): ContactInfo[] | null {
|
||||||
|
const cached = this.contactsMemoryCache.get(mode)
|
||||||
|
if (!cached) return null
|
||||||
|
if (cached.scope !== scope) return null
|
||||||
|
if (Date.now() - cached.updatedAt > this.contactsMemoryCacheTtlMs) return null
|
||||||
|
return this.cloneContacts(cached.contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setContactsMemoryCache(mode: 'lite' | 'full', scope: string, contacts: ContactInfo[]): void {
|
||||||
|
this.contactsMemoryCache.set(mode, {
|
||||||
|
scope,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
contacts: this.cloneContacts(contacts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||||
const isLiteMode = options?.lite === true
|
const isLiteMode = options?.lite === true
|
||||||
|
const mode: 'lite' | 'full' = isLiteMode ? 'lite' : 'full'
|
||||||
|
const cacheScope = this.getContactsCacheScope()
|
||||||
|
const cachedContacts = this.getContactsFromMemoryCache(mode, cacheScope)
|
||||||
|
if (cachedContacts) {
|
||||||
|
return { success: true, contacts: cachedContacts }
|
||||||
|
}
|
||||||
|
if (isLiteMode) {
|
||||||
|
const fullCachedContacts = this.getContactsFromMemoryCache('full', cacheScope)
|
||||||
|
if (fullCachedContacts) {
|
||||||
|
return { success: true, contacts: fullCachedContacts }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
const stageDurations: Array<{ stage: string; ms: number }> = []
|
const stageDurations: Array<{ stage: string; ms: number }> = []
|
||||||
const captureStage = (stage: string, stageStartedAt: number) => {
|
const captureStage = (stage: string, stageStartedAt: number) => {
|
||||||
@@ -1487,6 +1568,10 @@ class ChatService {
|
|||||||
.join(', ')
|
.join(', ')
|
||||||
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
|
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
|
||||||
}
|
}
|
||||||
|
this.setContactsMemoryCache(mode, cacheScope, result)
|
||||||
|
if (!isLiteMode) {
|
||||||
|
this.setContactsMemoryCache('lite', cacheScope, result)
|
||||||
|
}
|
||||||
return { success: true, contacts: result }
|
return { success: true, contacts: result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取通讯录失败:', e)
|
console.error('ChatService: 获取通讯录失败:', e)
|
||||||
@@ -2886,6 +2971,7 @@ class ChatService {
|
|||||||
this.sessionTablesCache.clear()
|
this.sessionTablesCache.clear()
|
||||||
this.messageTableColumnsCache.clear()
|
this.messageTableColumnsCache.clear()
|
||||||
this.messageDbCountSnapshotCache = null
|
this.messageDbCountSnapshotCache = null
|
||||||
|
this.contactsMemoryCache.clear()
|
||||||
this.refreshSessionStatsCacheScope(scope)
|
this.refreshSessionStatsCacheScope(scope)
|
||||||
this.refreshGroupMyMessageCountCacheScope(scope)
|
this.refreshGroupMyMessageCountCacheScope(scope)
|
||||||
}
|
}
|
||||||
@@ -5983,6 +6069,7 @@ class ChatService {
|
|||||||
if (includeContacts) {
|
if (includeContacts) {
|
||||||
this.avatarCache.clear()
|
this.avatarCache.clear()
|
||||||
this.contactCacheService.clear()
|
this.contactCacheService.clear()
|
||||||
|
this.contactsMemoryCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeMessages) {
|
if (includeMessages) {
|
||||||
|
|||||||
@@ -270,7 +270,9 @@ export class ConfigService {
|
|||||||
const inLockMode = this.isLockMode() && this.unlockPassword
|
const inLockMode = this.isLockMode() && this.unlockPassword
|
||||||
|
|
||||||
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
if (ENCRYPTED_BOOL_KEYS.has(key)) {
|
||||||
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
|
const boolValue = value === true || value === 'true'
|
||||||
|
// `false` 不需要写入 keychain,避免无意义触发 macOS 钥匙串弹窗
|
||||||
|
toStore = (boolValue ? this.safeEncrypt('true') : false) as ConfigSchema[K]
|
||||||
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
|
||||||
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
|
||||||
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
|
||||||
@@ -649,7 +651,7 @@ export class ConfigService {
|
|||||||
|
|
||||||
clearHelloSecret(): void {
|
clearHelloSecret(): void {
|
||||||
this.store.set('authHelloSecret', '' as any)
|
this.store.set('authHelloSecret', '' as any)
|
||||||
this.store.set('authUseHello', this.safeEncrypt('false') as any)
|
this.store.set('authUseHello', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 迁移 ===
|
// === 迁移 ===
|
||||||
@@ -658,13 +660,18 @@ export class ConfigService {
|
|||||||
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
|
||||||
// 如果已经是 safe: 或 lock: 前缀则跳过
|
// 如果已经是 safe: 或 lock: 前缀则跳过
|
||||||
const rawEnabled: any = this.store.get('authEnabled')
|
const rawEnabled: any = this.store.get('authEnabled')
|
||||||
if (typeof rawEnabled === 'boolean') {
|
if (rawEnabled === true || rawEnabled === 'true') {
|
||||||
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
|
this.store.set('authEnabled', this.safeEncrypt('true') as any)
|
||||||
|
} else if (rawEnabled === false || rawEnabled === 'false') {
|
||||||
|
// 保持 false 为明文布尔,避免冷启动访问 keychain
|
||||||
|
this.store.set('authEnabled', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUseHello: any = this.store.get('authUseHello')
|
const rawUseHello: any = this.store.get('authUseHello')
|
||||||
if (typeof rawUseHello === 'boolean') {
|
if (rawUseHello === true || rawUseHello === 'true') {
|
||||||
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
|
this.store.set('authUseHello', this.safeEncrypt('true') as any)
|
||||||
|
} else if (rawUseHello === false || rawUseHello === 'false') {
|
||||||
|
this.store.set('authUseHello', false as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPassword: any = this.store.get('authPassword')
|
const rawPassword: any = this.store.get('authPassword')
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export interface ExportOptions {
|
|||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
fileNameSuffix?: string
|
fileNameSuffix?: string
|
||||||
|
fileNamingMode?: 'classic' | 'date-range'
|
||||||
exportMedia?: boolean
|
exportMedia?: boolean
|
||||||
exportAvatars?: boolean
|
exportAvatars?: boolean
|
||||||
exportImages?: boolean
|
exportImages?: boolean
|
||||||
@@ -494,6 +495,80 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeExportFileNamePart(value: string): string {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/[<>:"\/\\|?*]/g, '_')
|
||||||
|
.replace(/\.+$/, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeFileNamingMode(value: unknown): 'classic' | 'date-range' {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'date-range' ? 'date-range' : 'classic'
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDateTokenBySeconds(seconds?: number): string | null {
|
||||||
|
if (!Number.isFinite(seconds) || (seconds || 0) <= 0) return null
|
||||||
|
const date = new Date(Math.floor(Number(seconds)) * 1000)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const d = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${y}${m}${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDateRangeFileNamePart(dateRange?: { start: number; end: number } | null): string {
|
||||||
|
const start = this.formatDateTokenBySeconds(dateRange?.start)
|
||||||
|
const end = this.formatDateTokenBySeconds(dateRange?.end)
|
||||||
|
if (start && end) {
|
||||||
|
if (start === end) return start
|
||||||
|
return start < end ? `${start}-${end}` : `${end}-${start}`
|
||||||
|
}
|
||||||
|
if (start) return `${start}-至今`
|
||||||
|
if (end) return `截至-${end}`
|
||||||
|
return '全部时间'
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSessionExportBaseName(
|
||||||
|
sessionId: string,
|
||||||
|
displayName: string,
|
||||||
|
options: ExportOptions
|
||||||
|
): string {
|
||||||
|
const baseName = this.sanitizeExportFileNamePart(displayName || sessionId) || this.sanitizeExportFileNamePart(sessionId) || 'session'
|
||||||
|
const suffix = this.sanitizeExportFileNamePart(options.fileNameSuffix || '')
|
||||||
|
const namingMode = this.normalizeFileNamingMode(options.fileNamingMode)
|
||||||
|
const parts = [baseName]
|
||||||
|
if (suffix) parts.push(suffix)
|
||||||
|
if (namingMode === 'date-range') {
|
||||||
|
parts.push(this.buildDateRangeFileNamePart(options.dateRange))
|
||||||
|
}
|
||||||
|
return this.sanitizeExportFileNamePart(parts.join('_')) || 'session'
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reserveUniqueOutputPath(preferredPath: string, reservedPaths: Set<string>): Promise<string> {
|
||||||
|
const dir = path.dirname(preferredPath)
|
||||||
|
const ext = path.extname(preferredPath)
|
||||||
|
const base = path.basename(preferredPath, ext)
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 10000; attempt += 1) {
|
||||||
|
const candidate = attempt === 0
|
||||||
|
? preferredPath
|
||||||
|
: path.join(dir, `${base}_${attempt + 1}${ext}`)
|
||||||
|
|
||||||
|
if (reservedPaths.has(candidate)) continue
|
||||||
|
|
||||||
|
const exists = await this.pathExists(candidate)
|
||||||
|
if (reservedPaths.has(candidate)) continue
|
||||||
|
if (exists) continue
|
||||||
|
|
||||||
|
reservedPaths.add(candidate)
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = path.join(dir, `${base}_${Date.now()}${ext}`)
|
||||||
|
reservedPaths.add(fallback)
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
private isCloneUnsupportedError(code: string | undefined): boolean {
|
private isCloneUnsupportedError(code: string | undefined): boolean {
|
||||||
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
|
return code === 'ENOTSUP' || code === 'ENOSYS' || code === 'EINVAL' || code === 'EXDEV' || code === 'ENOTTY'
|
||||||
}
|
}
|
||||||
@@ -8911,6 +8986,7 @@ class ExportService {
|
|||||||
? path.join(outputDir, 'texts')
|
? path.join(outputDir, 'texts')
|
||||||
: outputDir
|
: outputDir
|
||||||
const createdTaskDirs = new Set<string>()
|
const createdTaskDirs = new Set<string>()
|
||||||
|
const reservedOutputPaths = new Set<string>()
|
||||||
const ensureTaskDir = async (dirPath: string) => {
|
const ensureTaskDir = async (dirPath: string) => {
|
||||||
if (createdTaskDirs.has(dirPath)) return
|
if (createdTaskDirs.has(dirPath)) return
|
||||||
await fs.promises.mkdir(dirPath, { recursive: true })
|
await fs.promises.mkdir(dirPath, { recursive: true })
|
||||||
@@ -9159,10 +9235,8 @@ class ExportService {
|
|||||||
phaseLabel: '准备导出'
|
phaseLabel: '准备导出'
|
||||||
})
|
})
|
||||||
|
|
||||||
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
const fileNamingMode = this.normalizeFileNamingMode(effectiveOptions.fileNamingMode)
|
||||||
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
const safeName = this.buildSessionExportBaseName(sessionId, sessionInfo.displayName, effectiveOptions)
|
||||||
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
|
|
||||||
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
|
||||||
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
|
const sessionNameWithTypePrefix = effectiveOptions.sessionNameWithTypePrefix !== false
|
||||||
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
|
const sessionTypePrefix = sessionNameWithTypePrefix ? await this.getSessionFilePrefix(sessionId) : ''
|
||||||
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
|
const fileNameWithPrefix = `${sessionTypePrefix}${safeName}`
|
||||||
@@ -9180,13 +9254,13 @@ class ExportService {
|
|||||||
else if (effectiveOptions.format === 'txt') ext = '.txt'
|
else if (effectiveOptions.format === 'txt') ext = '.txt'
|
||||||
else if (effectiveOptions.format === 'weclone') ext = '.csv'
|
else if (effectiveOptions.format === 'weclone') ext = '.csv'
|
||||||
else if (effectiveOptions.format === 'html') ext = '.html'
|
else if (effectiveOptions.format === 'html') ext = '.html'
|
||||||
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
const preferredOutputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||||
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
|
const canTrySkipUnchanged = canTrySkipUnchangedTextSessions &&
|
||||||
typeof messageCountHint === 'number' &&
|
typeof messageCountHint === 'number' &&
|
||||||
messageCountHint >= 0 &&
|
messageCountHint >= 0 &&
|
||||||
typeof latestTimestampHint === 'number' &&
|
typeof latestTimestampHint === 'number' &&
|
||||||
latestTimestampHint > 0 &&
|
latestTimestampHint > 0 &&
|
||||||
await this.pathExists(outputPath)
|
await this.pathExists(preferredOutputPath)
|
||||||
if (canTrySkipUnchanged) {
|
if (canTrySkipUnchanged) {
|
||||||
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
|
const latestRecord = exportRecordService.getLatestRecord(sessionId, effectiveOptions.format)
|
||||||
const hasNoDataChange = Boolean(
|
const hasNoDataChange = Boolean(
|
||||||
@@ -9213,6 +9287,10 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outputPath = fileNamingMode === 'date-range'
|
||||||
|
? await this.reserveUniqueOutputPath(preferredOutputPath, reservedOutputPaths)
|
||||||
|
: preferredOutputPath
|
||||||
|
|
||||||
let result: { success: boolean; error?: string }
|
let result: { success: boolean; error?: string }
|
||||||
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
||||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress, control)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import { join, basename, dirname } from 'path'
|
import { join, basename, dirname } from 'path'
|
||||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
import { existsSync, readdirSync, readFileSync, statSync, chmodSync } from 'fs'
|
||||||
import { execFile, spawn } from 'child_process'
|
import { execFile, spawn } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
@@ -403,19 +403,71 @@ export class KeyServiceMac {
|
|||||||
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
return `'${String(text).replace(/'/g, `'\\''`)}'`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private collectMacKeyArtifactPaths(primaryBinaryPath: string): string[] {
|
||||||
|
const baseDir = dirname(primaryBinaryPath)
|
||||||
|
const names = ['xkey_helper', 'image_scan_helper', 'xkey_helper_macos', 'libwx_key.dylib']
|
||||||
|
const unique: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
const full = join(baseDir, name)
|
||||||
|
if (!existsSync(full)) continue
|
||||||
|
if (!unique.includes(full)) unique.push(full)
|
||||||
|
}
|
||||||
|
if (existsSync(primaryBinaryPath) && !unique.includes(primaryBinaryPath)) {
|
||||||
|
unique.unshift(primaryBinaryPath)
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureExecutableBitsBestEffort(paths: string[]): void {
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
const mode = statSync(p).mode
|
||||||
|
if ((mode & 0o111) !== 0) continue
|
||||||
|
chmodSync(p, mode | 0o111)
|
||||||
|
} catch {
|
||||||
|
// ignore: 可能无权限(例如 /Applications 下 root-owned 的 .app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureExecutableBitsWithElevation(paths: string[], timeoutMs: number): Promise<void> {
|
||||||
|
const existing = paths.filter(p => existsSync(p))
|
||||||
|
if (existing.length === 0) return
|
||||||
|
|
||||||
|
const quotedPaths = existing.map(p => this.shellSingleQuote(p)).join(' ')
|
||||||
|
const timeoutSec = Math.max(30, Math.ceil(timeoutMs / 1000))
|
||||||
|
const scriptLines = [
|
||||||
|
`set chmodCmd to "/bin/chmod +x ${quotedPaths}"`,
|
||||||
|
`set timeoutSec to ${timeoutSec}`,
|
||||||
|
'with timeout of timeoutSec seconds',
|
||||||
|
'do shell script chmodCmd with administrator privileges',
|
||||||
|
'end timeout'
|
||||||
|
]
|
||||||
|
|
||||||
|
await execFileAsync('/usr/bin/osascript', scriptLines.flatMap(line => ['-e', line]), {
|
||||||
|
timeout: timeoutMs + 10_000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private async getDbKeyByHelperElevated(
|
private async getDbKeyByHelperElevated(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onStatus?: (message: string, level: number) => void
|
onStatus?: (message: string, level: number) => void
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const helperPath = this.getHelperPath()
|
const helperPath = this.getHelperPath()
|
||||||
|
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||||
|
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||||
const waitMs = Math.max(timeoutMs, 30_000)
|
const waitMs = Math.max(timeoutMs, 30_000)
|
||||||
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
const timeoutSec = Math.ceil(waitMs / 1000) + 30
|
||||||
const pid = await this.getWeChatPid()
|
const pid = await this.getWeChatPid()
|
||||||
|
const chmodPart = artifactPaths.length > 0
|
||||||
|
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')}`
|
||||||
|
: ''
|
||||||
|
const runPart = `${this.shellSingleQuote(helperPath)} ${pid} ${waitMs}`
|
||||||
|
const privilegedCmd = chmodPart ? `${chmodPart} && ${runPart}` : runPart
|
||||||
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
// 用 AppleScript 的 quoted form 组装命令,避免复杂 shell 拼接导致整条失败
|
||||||
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
// 通过 try/on error 回传详细错误,避免只看到 "Command failed"
|
||||||
const scriptLines = [
|
const scriptLines = [
|
||||||
`set helperPath to ${JSON.stringify(helperPath)}`,
|
`set cmd to ${JSON.stringify(privilegedCmd)}`,
|
||||||
`set cmd to quoted form of helperPath & " ${pid} ${waitMs}"`,
|
|
||||||
`set timeoutSec to ${timeoutSec}`,
|
`set timeoutSec to ${timeoutSec}`,
|
||||||
'try',
|
'try',
|
||||||
'with timeout of timeoutSec seconds',
|
'with timeout of timeoutSec seconds',
|
||||||
@@ -751,10 +803,12 @@ export class KeyServiceMac {
|
|||||||
try {
|
try {
|
||||||
const helperPath = this.getImageScanHelperPath()
|
const helperPath = this.getImageScanHelperPath()
|
||||||
const ciphertextHex = ciphertext.toString('hex')
|
const ciphertextHex = ciphertext.toString('hex')
|
||||||
|
const artifactPaths = this.collectMacKeyArtifactPaths(helperPath)
|
||||||
|
this.ensureExecutableBitsBestEffort(artifactPaths)
|
||||||
|
|
||||||
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
// 1) 直接运行 helper(有正式签名的 debugger entitlement 时可用)
|
||||||
if (!this._needsElevation) {
|
if (!this._needsElevation) {
|
||||||
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false)
|
const direct = await this._spawnScanHelper(helperPath, pid, ciphertextHex, false, artifactPaths)
|
||||||
if (direct.key) return direct.key
|
if (direct.key) return direct.key
|
||||||
if (direct.permissionError) {
|
if (direct.permissionError) {
|
||||||
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
console.warn('[KeyServiceMac] task_for_pid 权限不足,切换到 osascript 提权模式')
|
||||||
@@ -765,7 +819,12 @@ export class KeyServiceMac {
|
|||||||
|
|
||||||
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
// 2) 通过 osascript 以管理员权限运行 helper(SIP 下 ad-hoc 签名无法获取 task_for_pid)
|
||||||
if (this._needsElevation) {
|
if (this._needsElevation) {
|
||||||
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true)
|
try {
|
||||||
|
await this.ensureExecutableBitsWithElevation(artifactPaths, 45_000)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[KeyServiceMac] elevated chmod failed before image scan:', e?.message || e)
|
||||||
|
}
|
||||||
|
const elevated = await this._spawnScanHelper(helperPath, pid, ciphertextHex, true, artifactPaths)
|
||||||
if (elevated.key) return elevated.key
|
if (elevated.key) return elevated.key
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -868,12 +927,19 @@ export class KeyServiceMac {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _spawnScanHelper(
|
private _spawnScanHelper(
|
||||||
helperPath: string, pid: number, ciphertextHex: string, elevated: boolean
|
helperPath: string,
|
||||||
|
pid: number,
|
||||||
|
ciphertextHex: string,
|
||||||
|
elevated: boolean,
|
||||||
|
artifactPaths: string[] = []
|
||||||
): Promise<{ key: string | null; permissionError: boolean }> {
|
): Promise<{ key: string | null; permissionError: boolean }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let child: ReturnType<typeof spawn>
|
let child: ReturnType<typeof spawn>
|
||||||
if (elevated) {
|
if (elevated) {
|
||||||
const shellCmd = `'${helperPath}' ${pid} ${ciphertextHex}`
|
const chmodPart = artifactPaths.length > 0
|
||||||
|
? `/bin/chmod +x ${artifactPaths.map(p => this.shellSingleQuote(p)).join(' ')} && `
|
||||||
|
: ''
|
||||||
|
const shellCmd = `${chmodPart}${this.shellSingleQuote(helperPath)} ${pid} ${ciphertextHex}`
|
||||||
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
child = spawn('/usr/bin/osascript', ['-e', `do shell script ${JSON.stringify(shellCmd)} with administrator privileges`],
|
||||||
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
{ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface ExportDefaultsSettingsPatch {
|
|||||||
format?: string
|
format?: string
|
||||||
avatars?: boolean
|
avatars?: boolean
|
||||||
dateRange?: ExportDateRangeSelection
|
dateRange?: ExportDateRangeSelection
|
||||||
|
fileNamingMode?: configService.ExportFileNamingMode
|
||||||
media?: configService.ExportDefaultMediaConfig
|
media?: configService.ExportDefaultMediaConfig
|
||||||
voiceAsText?: boolean
|
voiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
@@ -44,6 +45,11 @@ const exportExcelColumnOptions = [
|
|||||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
|
||||||
|
{ value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' },
|
||||||
|
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
|
||||||
|
]
|
||||||
|
|
||||||
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
|
||||||
|
|
||||||
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
|
||||||
@@ -56,12 +62,15 @@ export function ExportDefaultsSettingsForm({
|
|||||||
layout = 'stacked'
|
layout = 'stacked'
|
||||||
}: ExportDefaultsSettingsFormProps) {
|
}: ExportDefaultsSettingsFormProps) {
|
||||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||||
|
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
|
||||||
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
|
||||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
|
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
@@ -76,10 +85,11 @@ export function ExportDefaultsSettingsForm({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const [savedFormat, savedAvatars, savedDateRange, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultAvatars(),
|
configService.getExportDefaultAvatars(),
|
||||||
configService.getExportDefaultDateRange(),
|
configService.getExportDefaultDateRange(),
|
||||||
|
configService.getExportDefaultFileNamingMode(),
|
||||||
configService.getExportDefaultMedia(),
|
configService.getExportDefaultMedia(),
|
||||||
configService.getExportDefaultVoiceAsText(),
|
configService.getExportDefaultVoiceAsText(),
|
||||||
configService.getExportDefaultExcelCompactColumns(),
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
@@ -91,6 +101,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
setExportDefaultFormat(savedFormat || 'excel')
|
setExportDefaultFormat(savedFormat || 'excel')
|
||||||
setExportDefaultAvatars(savedAvatars ?? true)
|
setExportDefaultAvatars(savedAvatars ?? true)
|
||||||
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
|
||||||
|
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||||
setExportDefaultMedia(savedMedia ?? {
|
setExportDefaultMedia(savedMedia ?? {
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
@@ -114,15 +125,19 @@ export function ExportDefaultsSettingsForm({
|
|||||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
}
|
}
|
||||||
|
if (showExportFileNamingModeSelect && exportFileNamingModeDropdownRef.current && !exportFileNamingModeDropdownRef.current.contains(target)) {
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [showExportExcelColumnsSelect])
|
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
|
||||||
|
|
||||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||||
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
|
||||||
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
|
||||||
|
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
|
||||||
|
|
||||||
const notify = (text: string, success = true) => {
|
const notify = (text: string, success = true) => {
|
||||||
onNotify?.(text, success)
|
onNotify?.(text, success)
|
||||||
@@ -224,6 +239,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportExcelColumnsSelect(false)
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
setIsExportDateRangeDialogOpen(true)
|
setIsExportDateRangeDialogOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -247,6 +263,50 @@ export function ExportDefaultsSettingsForm({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="form-copy">
|
||||||
|
<label>导出文件命名方式</label>
|
||||||
|
<span className="form-hint">控制导出文件名是否包含时间范围</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-control">
|
||||||
|
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
|
||||||
|
setShowExportExcelColumnsSelect(false)
|
||||||
|
setIsExportDateRangeDialogOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="select-value">{exportFileNamingModeLabel}</span>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showExportFileNamingModeSelect && (
|
||||||
|
<div className="select-dropdown">
|
||||||
|
{exportFileNamingModeOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={async () => {
|
||||||
|
setExportDefaultFileNamingMode(option.value)
|
||||||
|
await configService.setExportDefaultFileNamingMode(option.value)
|
||||||
|
onDefaultsChanged?.({ fileNamingMode: option.value })
|
||||||
|
notify('已更新导出文件命名方式', true)
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="option-label">{option.label}</span>
|
||||||
|
<span className="option-desc">{option.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<div className="form-copy">
|
<div className="form-copy">
|
||||||
<label>Excel 列显示</label>
|
<label>Excel 列显示</label>
|
||||||
@@ -259,6 +319,7 @@ export function ExportDefaultsSettingsForm({
|
|||||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||||
|
setShowExportFileNamingModeSelect(false)
|
||||||
setIsExportDateRangeDialogOpen(false)
|
setIsExportDateRangeDialogOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1621,6 +1621,7 @@ function ExportPage() {
|
|||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState<TextExportFormat>('excel')
|
||||||
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
|
||||||
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
const [exportDefaultDateRangeSelection, setExportDefaultDateRangeSelection] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
|
||||||
|
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
|
||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
|
||||||
images: true,
|
images: true,
|
||||||
videos: true,
|
videos: true,
|
||||||
@@ -2270,7 +2271,7 @@ function ExportPage() {
|
|||||||
setIsBaseConfigLoading(true)
|
setIsBaseConfigLoading(true)
|
||||||
let isReady = true
|
let isReady = true
|
||||||
try {
|
try {
|
||||||
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, exportCacheScope] = await Promise.all([
|
const [savedPath, savedFormat, savedAvatars, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedImageDeepSearchOnMiss, savedSessionMap, savedContentMap, savedSessionRecordMap, savedSnsPostCount, savedWriteLayout, savedSessionNameWithTypePrefix, savedDefaultDateRange, savedFileNamingMode, exportCacheScope] = await Promise.all([
|
||||||
configService.getExportPath(),
|
configService.getExportPath(),
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultAvatars(),
|
configService.getExportDefaultAvatars(),
|
||||||
@@ -2287,6 +2288,7 @@ function ExportPage() {
|
|||||||
configService.getExportWriteLayout(),
|
configService.getExportWriteLayout(),
|
||||||
configService.getExportSessionNamePrefixEnabled(),
|
configService.getExportSessionNamePrefixEnabled(),
|
||||||
configService.getExportDefaultDateRange(),
|
configService.getExportDefaultDateRange(),
|
||||||
|
configService.getExportDefaultFileNamingMode(),
|
||||||
ensureExportCacheScope()
|
ensureExportCacheScope()
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -2318,6 +2320,7 @@ function ExportPage() {
|
|||||||
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
|
||||||
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
setExportDefaultConcurrency(savedConcurrency ?? 2)
|
||||||
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
setExportDefaultImageDeepSearchOnMiss(savedImageDeepSearchOnMiss ?? true)
|
||||||
|
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
|
||||||
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
const resolvedDefaultDateRange = resolveExportDateRangeConfig(savedDefaultDateRange)
|
||||||
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
setExportDefaultDateRangeSelection(resolvedDefaultDateRange)
|
||||||
setTimeRangeSelection(resolvedDefaultDateRange)
|
setTimeRangeSelection(resolvedDefaultDateRange)
|
||||||
@@ -4397,6 +4400,7 @@ function ExportPage() {
|
|||||||
displayNamePreference: options.displayNamePreference,
|
displayNamePreference: options.displayNamePreference,
|
||||||
exportConcurrency: options.exportConcurrency,
|
exportConcurrency: options.exportConcurrency,
|
||||||
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
imageDeepSearchOnMiss: options.imageDeepSearchOnMiss,
|
||||||
|
fileNamingMode: exportDefaultFileNamingMode,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
sessionNameWithTypePrefix,
|
sessionNameWithTypePrefix,
|
||||||
dateRange: options.useAllTime
|
dateRange: options.useAllTime
|
||||||
@@ -7089,6 +7093,9 @@ function ExportPage() {
|
|||||||
if (patch.dateRange) {
|
if (patch.dateRange) {
|
||||||
setExportDefaultDateRangeSelection(patch.dateRange)
|
setExportDefaultDateRangeSelection(patch.dateRange)
|
||||||
}
|
}
|
||||||
|
if (patch.fileNamingMode) {
|
||||||
|
setExportDefaultFileNamingMode(patch.fileNamingMode)
|
||||||
|
}
|
||||||
if (patch.media) {
|
if (patch.media) {
|
||||||
const mediaPatch = patch.media
|
const mediaPatch = patch.media
|
||||||
setExportDefaultMedia(mediaPatch)
|
setExportDefaultMedia(mediaPatch)
|
||||||
|
|||||||
@@ -1457,13 +1457,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
{
|
{
|
||||||
value: 'quote-top' as const,
|
value: 'quote-top' as const,
|
||||||
label: '引用在上',
|
label: '引用在上',
|
||||||
description: '更接近当前 WeFlow 风格',
|
|
||||||
successMessage: '已切换为引用在上样式'
|
successMessage: '已切换为引用在上样式'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'quote-bottom' as const,
|
value: 'quote-bottom' as const,
|
||||||
label: '正文在上',
|
label: '正文在上',
|
||||||
description: '更接近微信 / 密语风格',
|
|
||||||
successMessage: '已切换为正文在上样式'
|
successMessage: '已切换为正文在上样式'
|
||||||
}
|
}
|
||||||
].map(option => {
|
].map(option => {
|
||||||
@@ -1513,7 +1511,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="quote-layout-card-footer">
|
<div className="quote-layout-card-footer">
|
||||||
<div className="quote-layout-card-title-group">
|
<div className="quote-layout-card-title-group">
|
||||||
<span className="quote-layout-card-title">{option.label}</span>
|
<span className="quote-layout-card-title">{option.label}</span>
|
||||||
<span className="quote-layout-card-desc">{option.description}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const steps = [
|
|||||||
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
|
{ id: 'image', title: '图片密钥', desc: '获取 XOR 与 AES 密钥' },
|
||||||
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
|
{ id: 'security', title: '安全防护', desc: '保护你的数据' }
|
||||||
]
|
]
|
||||||
|
type SetupStepId = typeof steps[number]['id']
|
||||||
|
|
||||||
interface WelcomePageProps {
|
interface WelcomePageProps {
|
||||||
standalone?: boolean
|
standalone?: boolean
|
||||||
@@ -438,6 +439,48 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const jumpToStep = (stepId: SetupStepId) => {
|
||||||
|
const targetIndex = steps.findIndex(step => step.id === stepId)
|
||||||
|
if (targetIndex >= 0) setStepIndex(targetIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateDbStepBeforeNext = async (): Promise<string | null> => {
|
||||||
|
if (!dbPath) return '数据库目录步骤未完成:请先选择数据库目录'
|
||||||
|
if (dbPathValidationError) return `数据库目录步骤配置有误:${dbPathValidationError}`
|
||||||
|
try {
|
||||||
|
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||||
|
if (!Array.isArray(wxids) || wxids.length === 0) {
|
||||||
|
return '数据库目录步骤配置有误:当前目录下未找到可用账号数据(缺少 db_storage),请重新选择微信数据目录'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return `数据库目录步骤配置有误:目录读取失败,请确认该路径可访问(${String(e)})`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const findConfigIssueBeforeConnect = async (): Promise<{ stepId: SetupStepId; message: string } | null> => {
|
||||||
|
const dbIssue = await validateDbStepBeforeNext()
|
||||||
|
if (dbIssue) return { stepId: 'db', message: dbIssue }
|
||||||
|
|
||||||
|
let scannedWxids: Array<{ wxid: string }> = []
|
||||||
|
try {
|
||||||
|
scannedWxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||||
|
} catch {
|
||||||
|
scannedWxids = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wxid) {
|
||||||
|
return { stepId: 'key', message: '解密密钥步骤未完成:请先选择微信账号 (wxid)' }
|
||||||
|
}
|
||||||
|
if (!scannedWxids.some(item => item.wxid === wxid)) {
|
||||||
|
return { stepId: 'key', message: `解密密钥步骤配置有误:微信账号「${wxid}」不在当前数据库目录中,请重新选择账号` }
|
||||||
|
}
|
||||||
|
if (!decryptKey || decryptKey.length !== 64) {
|
||||||
|
return { stepId: 'key', message: '解密密钥步骤未完成:请填写 64 位解密密钥' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const canGoNext = () => {
|
const canGoNext = () => {
|
||||||
if (currentStep.id === 'intro') return true
|
if (currentStep.id === 'intro') return true
|
||||||
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
|
if (currentStep.id === 'db') return Boolean(dbPath) && !dbPathValidationError
|
||||||
@@ -453,7 +496,15 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = async () => {
|
||||||
|
if (currentStep.id === 'db') {
|
||||||
|
const dbStepIssue = await validateDbStepBeforeNext()
|
||||||
|
if (dbStepIssue) {
|
||||||
|
setError(dbStepIssue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!canGoNext()) {
|
if (!canGoNext()) {
|
||||||
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
|
if (currentStep.id === 'db' && !dbPath) setError('请先选择数据库目录')
|
||||||
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
|
else if (currentStep.id === 'db' && dbPathValidationError) setError(dbPathValidationError)
|
||||||
@@ -473,9 +524,12 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
if (!dbPath) { setError('请先选择数据库目录'); return }
|
const configIssue = await findConfigIssueBeforeConnect()
|
||||||
if (!wxid) { setError('请填写微信ID'); return }
|
if (configIssue) {
|
||||||
if (!decryptKey || decryptKey.length !== 64) { setError('请填写 64 位解密密钥'); return }
|
setError(configIssue.message)
|
||||||
|
jumpToStep(configIssue.stepId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsConnecting(true)
|
setIsConnecting(true)
|
||||||
setError('')
|
setError('')
|
||||||
@@ -484,7 +538,19 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || 'WCDB 连接失败')
|
const errorMessage = result.error || 'WCDB 连接失败'
|
||||||
|
if (errorMessage.includes('-3001')) {
|
||||||
|
const fallbackIssue = await findConfigIssueBeforeConnect()
|
||||||
|
if (fallbackIssue) {
|
||||||
|
setError(fallbackIssue.message)
|
||||||
|
jumpToStep(fallbackIssue.stepId)
|
||||||
|
} else {
|
||||||
|
setError(`数据库目录步骤配置有误:${errorMessage}`)
|
||||||
|
jumpToStep('db')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(errorMessage)
|
||||||
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||||
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
EXPORT_DEFAULT_AVATARS: 'exportDefaultAvatars',
|
||||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||||
|
EXPORT_DEFAULT_FILE_NAMING_MODE: 'exportDefaultFileNamingMode',
|
||||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||||
@@ -114,6 +115,8 @@ export interface ExportDefaultMediaConfig {
|
|||||||
files: boolean
|
files: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExportFileNamingMode = 'classic' | 'date-range'
|
||||||
|
|
||||||
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
|
||||||
export type QuoteLayout = 'quote-top' | 'quote-bottom'
|
export type QuoteLayout = 'quote-top' | 'quote-bottom'
|
||||||
export type UpdateChannel = 'stable' | 'preview' | 'dev'
|
export type UpdateChannel = 'stable' | 'preview' | 'dev'
|
||||||
@@ -434,6 +437,18 @@ export async function setExportDefaultDateRange(range: ExportDefaultDateRangeCon
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取导出默认文件命名方式
|
||||||
|
export async function getExportDefaultFileNamingMode(): Promise<ExportFileNamingMode | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE)
|
||||||
|
if (value === 'classic' || value === 'date-range') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认文件命名方式
|
||||||
|
export async function setExportDefaultFileNamingMode(mode: ExportFileNamingMode): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FILE_NAMING_MODE, mode)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取导出默认媒体设置
|
// 获取导出默认媒体设置
|
||||||
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
|
export async function getExportDefaultMedia(): Promise<ExportDefaultMediaConfig | null> {
|
||||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -1005,6 +1005,7 @@ export interface ExportOptions {
|
|||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
txtColumns?: string[]
|
txtColumns?: string[]
|
||||||
|
fileNamingMode?: 'classic' | 'date-range'
|
||||||
sessionLayout?: 'shared' | 'per-session'
|
sessionLayout?: 'shared' | 'per-session'
|
||||||
sessionNameWithTypePrefix?: boolean
|
sessionNameWithTypePrefix?: boolean
|
||||||
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
|
||||||
|
|||||||
Reference in New Issue
Block a user