mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-03 15:08:25 +00:00
Compare commits
165 Commits
v4.1.5
...
nightly-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8d272d6e | ||
|
|
0047685f54 | ||
|
|
327dc85d14 | ||
|
|
40c29e494c | ||
|
|
fa2a000624 | ||
|
|
861b24cef1 | ||
|
|
ee1977384e | ||
|
|
5d08505f62 | ||
|
|
ab21124327 | ||
|
|
1df792ec9c | ||
|
|
a8fa6e5987 | ||
|
|
1d69c5a78d | ||
|
|
0ae7ba3e11 | ||
|
|
c421ca7f2f | ||
|
|
ea4fff5b10 | ||
|
|
e0b0e38271 | ||
|
|
510b956649 | ||
|
|
17b8af4bc4 | ||
|
|
617b400884 | ||
|
|
a58518ccb5 | ||
|
|
cdd17d919e | ||
|
|
4580cef7f2 | ||
|
|
6f9c765bab | ||
|
|
5b56b2e0be | ||
|
|
b0cc811807 | ||
|
|
eb540d5c13 | ||
|
|
e308293cf6 | ||
|
|
9ed4659c5c | ||
|
|
f5f2b76914 | ||
|
|
551a065497 | ||
|
|
88d7e38d82 | ||
|
|
65e6cb22dd | ||
|
|
689a396f6e | ||
|
|
512ea84850 | ||
|
|
1542e583f7 | ||
|
|
c488dcc3c6 | ||
|
|
1594e0e24b | ||
|
|
1e1fa77621 | ||
|
|
3270c17514 | ||
|
|
e635942e3d | ||
|
|
64dc2858a7 | ||
|
|
d05496bb3d | ||
|
|
bb94553fff | ||
|
|
113216b7ba | ||
|
|
55181edaa8 | ||
|
|
4f01a7b577 | ||
|
|
ea21111037 | ||
|
|
fba9f1de42 | ||
|
|
7d0b8db7a6 | ||
|
|
12faa31e34 | ||
|
|
74945b1752 | ||
|
|
a7c66517d2 | ||
|
|
adf187ddf5 | ||
|
|
614d897dd2 | ||
|
|
ec9a7d68e6 | ||
|
|
79dd91b270 | ||
|
|
b26bcd7603 | ||
|
|
a65468191b | ||
|
|
4ed5271703 | ||
|
|
f2afe2a977 | ||
|
|
430b0f30c9 | ||
|
|
8aac9a795e | ||
|
|
b9405765f9 | ||
|
|
90856b3812 | ||
|
|
1e78af3c25 | ||
|
|
4be232b951 | ||
|
|
59d5c2762d | ||
|
|
21a55439ec | ||
|
|
c4a35f5c15 | ||
|
|
b42f761011 | ||
|
|
46e2e64e65 | ||
|
|
54ed35ffb3 | ||
|
|
3e379957e1 | ||
|
|
b64525487e | ||
|
|
e544f5c862 | ||
|
|
669759c52e | ||
|
|
4a13d3209b | ||
|
|
be069e9aed | ||
|
|
0b20ee1aa2 | ||
|
|
4692216325 | ||
|
|
69128062fe | ||
|
|
f81ba3028d | ||
|
|
73a948c528 | ||
|
|
6d652130e6 | ||
|
|
9e6f8077f7 | ||
|
|
40342ca824 | ||
|
|
4da9f1e6cf | ||
|
|
93b55fe370 | ||
|
|
ee5e7d2586 | ||
|
|
4f4e09c3de | ||
|
|
d537d81f1c | ||
|
|
26c6700152 | ||
|
|
49fb96d7a3 | ||
|
|
d256ee5696 | ||
|
|
bd70a7bfa8 | ||
|
|
3fb09bad0d | ||
|
|
06079659af | ||
|
|
22d8049c2c | ||
|
|
5f6b0e8960 | ||
|
|
9b8da7774d | ||
|
|
eabed55a7a | ||
|
|
32cc74f99c | ||
|
|
ffc4cc3d96 | ||
|
|
007cf57efd | ||
|
|
c6dba71197 | ||
|
|
8aa162e294 | ||
|
|
51d6dec7ff | ||
|
|
f1b2762769 | ||
|
|
d126be2aa5 | ||
|
|
ea034ee76a | ||
|
|
39634a690c | ||
|
|
a7001eb6da | ||
|
|
71e3540f18 | ||
|
|
78cadfd352 | ||
|
|
da15f829d3 | ||
|
|
bb60694013 | ||
|
|
b3758d2baf | ||
|
|
bc794e9a44 | ||
|
|
c80115d0f7 | ||
|
|
6277576249 | ||
|
|
2201d369fa | ||
|
|
9f4e4790f5 | ||
|
|
501e373e38 | ||
|
|
b2cf7c92d5 | ||
|
|
e92e13c045 | ||
|
|
f3dec958b0 | ||
|
|
0cf8ea8166 | ||
|
|
74b830dd79 | ||
|
|
8668c168a7 | ||
|
|
8b8c5f33ce | ||
|
|
2fcbb026df | ||
|
|
66ee72380d | ||
|
|
4f16345351 | ||
|
|
5110618996 | ||
|
|
bf51368cf4 | ||
|
|
d6054745d6 | ||
|
|
a4731f25f8 | ||
|
|
6c4507e495 | ||
|
|
c8e0160d5c | ||
|
|
ac40a81901 | ||
|
|
ca38a68a75 | ||
|
|
64be2dd562 | ||
|
|
ea2abb6c72 | ||
|
|
011e2ff37a | ||
|
|
cfa335564a | ||
|
|
3d1493b0a6 | ||
|
|
a46b52e603 | ||
|
|
3c0683b9f8 | ||
|
|
3214c2804e | ||
|
|
83f50cbaee | ||
|
|
61ef10de9b | ||
|
|
73f36d6b29 | ||
|
|
666a1a3296 | ||
|
|
acec2e95a2 | ||
|
|
d26e7e78a1 | ||
|
|
77e5c44673 | ||
|
|
619cc84d15 | ||
|
|
22b85439d3 | ||
|
|
64995c25a8 | ||
|
|
1655b5ae78 | ||
|
|
3d3f6d058e | ||
|
|
104a04c5de | ||
|
|
e12193aa40 | ||
|
|
2e1c0e6c54 | ||
|
|
b5a371da87 |
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
|
target-branch: "dev"
|
||||||
134
.github/workflows/anti-spam.yml
vendored
Normal file
134
.github/workflows/anti-spam.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: Anti-Spam
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-spam:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check for spam
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issue = context.payload.issue;
|
||||||
|
const title = (issue.title || '').toLowerCase();
|
||||||
|
const body = (issue.body || '').toLowerCase();
|
||||||
|
const text = title + ' ' + body;
|
||||||
|
|
||||||
|
// 博彩/赌球类
|
||||||
|
const gamblingPatterns = [
|
||||||
|
/世界杯.*买球/, /买球.*世界杯/,
|
||||||
|
/世界杯.*下注/, /世界杯.*竞猜/,
|
||||||
|
/世界杯.*投注/, /世界杯.*押注/,
|
||||||
|
/世界杯.*彩票/, /世界杯.*平台/,
|
||||||
|
/世界杯.*app/, /世界杯.*软件/,
|
||||||
|
/世界杯.*网站/, /世界杯.*网址/,
|
||||||
|
/足球.*买球/, /买球.*足球/,
|
||||||
|
/足球.*投注/, /足球.*押注/,
|
||||||
|
/足球.*竞猜/, /足球.*平台/,
|
||||||
|
/篮球.*买球/, /篮球.*投注/,
|
||||||
|
/体育.*投注/, /体育.*竞猜/,
|
||||||
|
/体育.*买球/, /体育.*押注/,
|
||||||
|
/赌球/, /赌博.*网站/, /赌博.*平台/,
|
||||||
|
/博彩/, /博彩.*网站/, /博彩.*平台/,
|
||||||
|
/正规.*买球/, /官方.*买球/,
|
||||||
|
/买球.*网站/, /买球.*app/,
|
||||||
|
/买球.*软件/, /买球.*网址/,
|
||||||
|
/买球.*平台/, /买球.*技巧/,
|
||||||
|
/投注.*网站/, /投注.*平台/,
|
||||||
|
/押注.*网站/, /押注.*平台/,
|
||||||
|
/竞猜.*网站/, /竞猜.*平台/,
|
||||||
|
/彩票.*网站/, /彩票.*平台/,
|
||||||
|
/欧洲杯.*买球/, /欧冠.*买球/,
|
||||||
|
/nba.*买球/, /nba.*投注/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 色情/交友类
|
||||||
|
const adultPatterns = [
|
||||||
|
/约炮/, /一夜情/, /外围/,
|
||||||
|
/包养/, /援交/, /陪聊/,
|
||||||
|
/成人.*网站/, /成人.*视频/,
|
||||||
|
/av.*网站/, /黄色.*网站/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 贷款/金融诈骗类
|
||||||
|
const financePatterns = [
|
||||||
|
/秒到账.*贷款/, /无抵押.*贷款/,
|
||||||
|
/征信.*贷款/, /黑户.*贷款/,
|
||||||
|
/快速.*放款/, /私人.*放贷/,
|
||||||
|
/刷单/, /兼职.*日入/, /兼职.*月入/,
|
||||||
|
/网赚/, /躺赚/, /被动收入.*平台/,
|
||||||
|
/虚拟货币.*投资/, /usdt.*投资/,
|
||||||
|
/炒币.*平台/, /数字货币.*平台/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 垃圾推广类
|
||||||
|
const spamPromoPatterns = [
|
||||||
|
/代刷/, /粉丝.*购买/, /涨粉/,
|
||||||
|
/seo.*优化/, /快速排名/,
|
||||||
|
/微商/, /代理.*招募/,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 账号特征检测(新账号 + 无 contribution)
|
||||||
|
const allPatterns = [
|
||||||
|
...gamblingPatterns,
|
||||||
|
...adultPatterns,
|
||||||
|
...financePatterns,
|
||||||
|
...spamPromoPatterns,
|
||||||
|
];
|
||||||
|
|
||||||
|
const isSpam = allPatterns.some(pattern => pattern.test(text));
|
||||||
|
|
||||||
|
// 额外检测:标题超短且含可疑关键词(常见于批量刷单)
|
||||||
|
const suspiciousShort = title.length < 10 && /(买球|投注|博彩|赌博|下注|押注)/.test(title);
|
||||||
|
|
||||||
|
if (isSpam || suspiciousShort) {
|
||||||
|
// 确保 spam label 存在
|
||||||
|
try {
|
||||||
|
await github.rest.issues.createLabel({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
name: 'spam',
|
||||||
|
color: 'e4e669',
|
||||||
|
description: 'Spam issue'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// label 已存在,忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
body: '此 issue 已被自动识别为垃圾内容并关闭。\n\nThis issue has been automatically identified as spam and closed.'
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
labels: ['spam']
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
state: 'closed',
|
||||||
|
state_reason: 'not_planned'
|
||||||
|
});
|
||||||
|
|
||||||
|
await github.rest.issues.lock({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: issue.number,
|
||||||
|
lock_reason: 'spam'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Closed spam issue #${issue.number}: ${issue.title}`);
|
||||||
|
}
|
||||||
327
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
327
.github/workflows/dev-daily-fixed.yml
vendored
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
name: Dev Daily
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
FIXED_DEV_TAG: nightly-dev
|
||||||
|
TARGET_BRANCH: dev
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
dev_version: ${{ steps.meta.outputs.dev_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Generate daily dev version
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
|
MONTH="$(TZ=Asia/Shanghai date +%-m)"
|
||||||
|
DAY="$(TZ=Asia/Shanghai date +%-d)"
|
||||||
|
DEV_VERSION="${YEAR_2}.${MONTH}.${DAY}"
|
||||||
|
echo "dev_version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Dev version: $DEV_VERSION"
|
||||||
|
|
||||||
|
- name: Ensure fixed prerelease exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release edit "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --prerelease
|
||||||
|
else
|
||||||
|
gh release create "$FIXED_DEV_TAG" --repo "$GITHUB_REPOSITORY" --title "Daily Dev Build" --notes "开发版发布页" --prerelease
|
||||||
|
fi
|
||||||
|
|
||||||
|
dev-mac-arm64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package macOS arm64 dev artifacts
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-arm64.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-linux:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Linux dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-linux.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-win-x64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows x64 dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=dev' '--config.artifactName=${productName}-dev-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
dev-win-arm64:
|
||||||
|
needs: prepare
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set dev version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.dev_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows arm64 dev artifacts
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=dev-arm64' '--config.artifactName=${productName}-dev-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 assets to fixed release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_DEV_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
update-dev-release-notes:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- dev-mac-arm64
|
||||||
|
- dev-linux
|
||||||
|
- dev-win-x64
|
||||||
|
- dev-win-arm64
|
||||||
|
if: always() && needs.prepare.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update fixed dev release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
FIXED_DEV_TAG: ${{ env.FIXED_DEV_TAG }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$FIXED_DEV_TAG"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found, skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(pick_asset "dev-x64-Setup[.]exe$")"
|
||||||
|
WINDOWS_ARM64_ASSET="$(pick_asset "dev-arm64-Setup[.]exe$")"
|
||||||
|
MAC_ASSET="$(pick_asset "dev-arm64[.]dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "dev-linux[.]tar[.]gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "dev-linux[.]AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > dev_release_notes.md <<EOF
|
||||||
|
## Daily Dev Build
|
||||||
|
- 该发布页为 **开发版**。
|
||||||
|
- 当前构建版本:\`${{ needs.prepare.outputs.dev_version }}\`
|
||||||
|
- 此版本为每日构建的开发版,包含最新的功能和修复,但可能不够稳定,仅推荐用于测试和验证。
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
|
- macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
- 该发布页的同名资源会被后续构建覆盖,请勿将其视作长期归档版本。
|
||||||
|
- 如某个平台资源暂未生成,请进入发布页查看最新状态:$RELEASE_PAGE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --title "Daily Dev Build" --notes-file dev_release_notes.md
|
||||||
368
.github/workflows/preview-nightly-main.yml
vendored
Normal file
368
.github/workflows/preview-nightly-main.yml
vendored
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
name: Preview Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# GitHub Actions schedule uses UTC. 16:00 UTC = 北京时间次日 00:00
|
||||||
|
- cron: "0 16 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
FIXED_PREVIEW_TAG: nightly-preview
|
||||||
|
TARGET_BRANCH: main
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.meta.outputs.should_build }}
|
||||||
|
preview_version: ${{ steps.meta.outputs.preview_version }}
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Decide whether to build and generate preview version
|
||||||
|
id: meta
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git fetch origin main --depth=1
|
||||||
|
COMMITS_24H="$(git rev-list --count --since='24 hours ago' origin/main)"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
elif [ "$COMMITS_24H" -gt 0 ]; then
|
||||||
|
SHOULD_BUILD=true
|
||||||
|
else
|
||||||
|
SHOULD_BUILD=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
YEAR_2="$(TZ=Asia/Shanghai date +%y)"
|
||||||
|
YEARLY_RUN_COUNT=1
|
||||||
|
LAST_VERSION="$(gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' 2>/dev/null | grep -Eo '0\.[0-9]{2}\.[0-9]+' | head -n 1 || true)"
|
||||||
|
if [[ "$LAST_VERSION" =~ ^0\.([0-9]{2})\.([0-9]+)$ ]]; then
|
||||||
|
LAST_YEAR="${BASH_REMATCH[1]}"
|
||||||
|
LAST_COUNT="${BASH_REMATCH[2]}"
|
||||||
|
if [ "$LAST_YEAR" = "$YEAR_2" ]; then
|
||||||
|
YEARLY_RUN_COUNT=$((LAST_COUNT + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREVIEW_VERSION="0.${YEAR_2}.${YEARLY_RUN_COUNT}"
|
||||||
|
|
||||||
|
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "preview_version=$PREVIEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Preview version: $PREVIEW_VERSION (commits in last 24h on main: $COMMITS_24H, yearly count: $YEARLY_RUN_COUNT)"
|
||||||
|
|
||||||
|
- name: Ensure fixed preview prerelease exists
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
gh release edit "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --prerelease
|
||||||
|
else
|
||||||
|
gh release create "$FIXED_PREVIEW_TAG" --repo "$GITHUB_REPOSITORY" --title "Preview Nightly Build" --notes "预览版发布页" --prerelease
|
||||||
|
fi
|
||||||
|
|
||||||
|
preview-mac-arm64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: macos-14
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package macOS arm64 preview artifacts
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
|
npx electron-builder --mac dmg --arm64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-arm64.${ext}'
|
||||||
|
|
||||||
|
- name: Upload macOS arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
preview-linux:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Linux preview artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx electron-builder --linux --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-linux.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Linux assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
preview-win-x64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows x64 preview artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --x64 --publish never '--config.publish.channel=preview' '--config.artifactName=${productName}-preview-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows x64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
preview-win-arm64:
|
||||||
|
needs: prepare
|
||||||
|
if: needs.prepare.outputs.should_build == 'true'
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out git repository
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: ${{ env.TARGET_BRANCH }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "npm"
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Set preview version
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ needs.prepare.outputs.preview_version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Build Frontend & Type Check
|
||||||
|
run: |
|
||||||
|
npx tsc
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
- name: Package Windows arm64 preview artifacts
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
npx electron-builder --win nsis --arm64 --publish never '--config.publish.channel=preview-arm64' '--config.artifactName=${productName}-preview-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Upload Windows arm64 assets to fixed preview release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
assets=()
|
||||||
|
while IFS= read -r file; do
|
||||||
|
assets+=("$file")
|
||||||
|
done < <(find release -maxdepth 1 -type f | sort)
|
||||||
|
if [ "${#assets[@]}" -eq 0 ]; then
|
||||||
|
echo "No release files found in ./release"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh release upload "$FIXED_PREVIEW_TAG" "${assets[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||||
|
|
||||||
|
update-preview-release-notes:
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- preview-mac-arm64
|
||||||
|
- preview-linux
|
||||||
|
- preview-win-x64
|
||||||
|
- preview-win-arm64
|
||||||
|
if: needs.prepare.outputs.should_build == 'true' && always()
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Update preview release notes
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="$FIXED_PREVIEW_TAG"
|
||||||
|
CURRENT_PREVIEW_VERSION="${{ needs.prepare.outputs.preview_version }}"
|
||||||
|
REPO="$GITHUB_REPOSITORY"
|
||||||
|
RELEASE_PAGE="https://github.com/$REPO/releases/tag/$TAG"
|
||||||
|
|
||||||
|
if ! gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG not found (possibly all publish jobs failed), skip notes update."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSETS_JSON="$(gh release view "$TAG" --repo "$REPO" --json assets)"
|
||||||
|
|
||||||
|
pick_asset() {
|
||||||
|
local pattern="$1"
|
||||||
|
echo "$ASSETS_JSON" | jq -r --arg p "$pattern" '[.assets[].name | select(test($p))][0] // ""'
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_ASSET="$(pick_asset "x64.*[.]exe$")"
|
||||||
|
if [ -z "$WINDOWS_ASSET" ]; then
|
||||||
|
WINDOWS_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("[.]exe$")) | select(test("arm64") | not)][0] // ""')"
|
||||||
|
fi
|
||||||
|
WINDOWS_ARM64_ASSET="$(pick_asset "arm64.*[.]exe$")"
|
||||||
|
MAC_ASSET="$(pick_asset "[.]dmg$")"
|
||||||
|
LINUX_TAR_ASSET="$(pick_asset "[.]tar[.]gz$")"
|
||||||
|
LINUX_APPIMAGE_ASSET="$(pick_asset "[.]AppImage$")"
|
||||||
|
|
||||||
|
build_link() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -n "$name" ]; then
|
||||||
|
echo "https://github.com/$REPO/releases/download/$TAG/$name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
|
cat > preview_release_notes.md <<EOF
|
||||||
|
## Preview Nightly 说明
|
||||||
|
- 该版本为 **预览版**,用于提前体验即将发布的功能与修复。
|
||||||
|
- 可能包含尚未完全稳定的改动,不建议长期使用
|
||||||
|
- 当前版本号:\`$CURRENT_PREVIEW_VERSION\`
|
||||||
|
|
||||||
|
## 下载
|
||||||
|
- Windows x64: ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
|
- macOS(Apple Silicon): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
|
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
|
## macOS 安装提示
|
||||||
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
|
> 如某个平台链接暂未生成,请前往发布页查看最新资源:$RELEASE_PAGE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
gh release edit "$TAG" --repo "$REPO" --notes-file preview_release_notes.md
|
||||||
74
.github/workflows/release.yml
vendored
74
.github/workflows/release.yml
vendored
@@ -10,6 +10,7 @@ permissions:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-mac-arm64:
|
release-mac-arm64:
|
||||||
@@ -46,9 +47,28 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
export ELECTRON_BUILDER_BINARIES_MIRROR="https://github.com/electron-userland/electron-builder-binaries/releases/download/"
|
||||||
|
echo "Using ELECTRON_BUILDER_BINARIES_MIRROR=$ELECTRON_BUILDER_BINARIES_MIRROR"
|
||||||
npx electron-builder --mac dmg --arm64 --publish always
|
npx electron-builder --mac dmg --arm64 --publish always
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
for YML_FILE in latest-mac.yml latest-arm64-mac.yml; do
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "$YML_FILE" --output "/tmp/$YML_FILE" 2>/dev/null || continue
|
||||||
|
if ! grep -q 'minimumVersion' "/tmp/$YML_FILE"; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> "/tmp/$YML_FILE"
|
||||||
|
fi
|
||||||
|
gh release upload "$TAG" --repo "$REPO" "/tmp/$YML_FILE" --clobber
|
||||||
|
done
|
||||||
|
|
||||||
release-linux:
|
release-linux:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -85,6 +105,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npx electron-builder --linux --publish always
|
npx electron-builder --linux --publish always
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "latest-linux.yml" --output "/tmp/latest-linux.yml" 2>/dev/null
|
||||||
|
if [ -f /tmp/latest-linux.yml ] && ! grep -q 'minimumVersion' /tmp/latest-linux.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-linux.yml
|
||||||
|
gh release upload "$TAG" --repo "$REPO" /tmp/latest-linux.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
@@ -121,6 +155,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
npx electron-builder --win nsis --x64 --publish always '--config.artifactName=${productName}-${version}-x64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "latest.yml" --output "/tmp/latest.yml" 2>/dev/null
|
||||||
|
if [ -f /tmp/latest.yml ] && ! grep -q 'minimumVersion' /tmp/latest.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest.yml
|
||||||
|
gh release upload "$TAG" --repo "$REPO" /tmp/latest.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
release-windows-arm64:
|
release-windows-arm64:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
@@ -157,6 +205,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
npx electron-builder --win nsis --arm64 --publish always '--config.publish.channel=latest-arm64' '--config.artifactName=${productName}-${version}-arm64-Setup.${ext}'
|
||||||
|
|
||||||
|
- name: Inject minimumVersion into latest yml
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_REF_NAME}
|
||||||
|
REPO=${{ github.repository }}
|
||||||
|
MINIMUM_VERSION="4.1.7"
|
||||||
|
gh release download "$TAG" --repo "$REPO" --pattern "latest-arm64.yml" --output "/tmp/latest-arm64.yml" 2>/dev/null
|
||||||
|
if [ -f /tmp/latest-arm64.yml ] && ! grep -q 'minimumVersion' /tmp/latest-arm64.yml; then
|
||||||
|
echo "minimumVersion: $MINIMUM_VERSION" >> /tmp/latest-arm64.yml
|
||||||
|
gh release upload "$TAG" --repo "$REPO" /tmp/latest-arm64.yml --clobber
|
||||||
|
fi
|
||||||
|
|
||||||
update-release-notes:
|
update-release-notes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
@@ -190,7 +252,6 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
WINDOWS_ARM64_ASSET="$(echo "$ASSETS_JSON" | jq -r '[.assets[].name | select(test("arm64.*\\.exe$"))][0] // ""')"
|
||||||
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
MAC_ASSET="$(pick_asset "\\.dmg$")"
|
||||||
LINUX_DEB_ASSET="$(pick_asset "\\.deb$")"
|
|
||||||
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
LINUX_TAR_ASSET="$(pick_asset "\\.tar\\.gz$")"
|
||||||
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
LINUX_APPIMAGE_ASSET="$(pick_asset "\\.AppImage$")"
|
||||||
|
|
||||||
@@ -204,7 +265,6 @@ jobs:
|
|||||||
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
WINDOWS_URL="$(build_link "$WINDOWS_ASSET")"
|
||||||
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
WINDOWS_ARM64_URL="$(build_link "$WINDOWS_ARM64_ASSET")"
|
||||||
MAC_URL="$(build_link "$MAC_ASSET")"
|
MAC_URL="$(build_link "$MAC_ASSET")"
|
||||||
LINUX_DEB_URL="$(build_link "$LINUX_DEB_ASSET")"
|
|
||||||
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
LINUX_TAR_URL="$(build_link "$LINUX_TAR_ASSET")"
|
||||||
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
LINUX_APPIMAGE_URL="$(build_link "$LINUX_APPIMAGE_ASSET")"
|
||||||
|
|
||||||
@@ -219,14 +279,12 @@ jobs:
|
|||||||
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
- Windows x64(Win10+): ${WINDOWS_URL:-$RELEASE_PAGE}
|
||||||
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
- Windows arm64: ${WINDOWS_ARM64_URL:-$RELEASE_PAGE}
|
||||||
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
- macOS(M系列芯片): ${MAC_URL:-$RELEASE_PAGE}
|
||||||
- Linux (.deb) (即将废弃): ${LINUX_DEB_URL:-$RELEASE_PAGE}
|
|
||||||
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
- Linux (.tar.gz): ${LINUX_TAR_URL:-$RELEASE_PAGE}
|
||||||
- linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
- Linux (.AppImage): ${LINUX_APPIMAGE_URL:-$RELEASE_PAGE}
|
||||||
|
|
||||||
## macOS 安装提示(未知来源)
|
## macOS 安装提示
|
||||||
- 若打开时提示“来自未知开发者”或“无法验证开发者”,请到「系统设置 -> 隐私与安全性」中允许打开该应用。
|
- 如果被系统提示已损坏,你需要在终端执行以下命令去除隔离标记:
|
||||||
- 如果仍被系统拦截,请在终端执行以下命令去除隔离标记:
|
- \`xattr -dr com.apple.quarantine "/Applications/WeFlow.app"\`
|
||||||
- `xattr -dr com.apple.quarantine "/Applications/WeFlow.app"`
|
|
||||||
- 执行后重新打开 WeFlow。
|
- 执行后重新打开 WeFlow。
|
||||||
|
|
||||||
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
> 如果某个平台链接暂时未生成,可进入完整发布页查看全部资源:$RELEASE_PAGE
|
||||||
|
|||||||
87
.github/workflows/security-scan.yml
vendored
Normal file
87
.github/workflows/security-scan.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *' # 每天 UTC 02:00
|
||||||
|
workflow_dispatch: # 手动触发
|
||||||
|
pull_request: # PR 时触发
|
||||||
|
branches: [ main, dev ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security-scan:
|
||||||
|
name: Security Scan (${{ matrix.branch }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout ${{ matrix.branch }}
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ matrix.branch }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm' # 使用 npm 缓存加速
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --ignore-scripts
|
||||||
|
|
||||||
|
# 1. npm audit - 检查依赖漏洞
|
||||||
|
- name: Dependency vulnerability audit
|
||||||
|
run: npm audit --audit-level=moderate
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# 2. CodeQL 静态分析
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: javascript, typescript
|
||||||
|
queries: security-and-quality
|
||||||
|
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: '/language:javascript-typescript/branch:${{ matrix.branch }}'
|
||||||
|
|
||||||
|
# 3. 密钥/敏感信息扫描
|
||||||
|
- name: Secret scanning with Gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
# 动态获取所有分支并扫描
|
||||||
|
scan-all-branches:
|
||||||
|
name: Scan additional branches
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Run npm audit on all branches
|
||||||
|
run: |
|
||||||
|
git branch -r | grep -v HEAD | sed 's|origin/||' | tr -d ' ' | while read branch; do
|
||||||
|
echo "===== Auditing branch: $branch ====="
|
||||||
|
git checkout "$branch" 2>/dev/null || continue
|
||||||
|
# 尝试安装并审计
|
||||||
|
npm ci --ignore-scripts --silent 2>/dev/null || npm install --ignore-scripts --silent 2>/dev/null || true
|
||||||
|
npm audit --audit-level=moderate 2>/dev/null || true
|
||||||
|
done
|
||||||
|
continue-on-error: true
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -70,3 +70,5 @@ resources/wx_send
|
|||||||
概述.md
|
概述.md
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
/pnpm-workspace.yaml
|
/pnpm-workspace.yaml
|
||||||
|
wechat-research-site
|
||||||
|
.codex
|
||||||
23
.gitleaks.toml
Normal file
23
.gitleaks.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
title = "Gitleaks Config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
# 继承默认规则
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# 排除误报路径
|
||||||
|
[[rules]]
|
||||||
|
id = "curl-auth-header"
|
||||||
|
[rules.allowlist]
|
||||||
|
paths = [
|
||||||
|
'''docs/HTTP-API\.md'''
|
||||||
|
]
|
||||||
|
regexes = [
|
||||||
|
'''YOUR_TOKEN'''
|
||||||
|
]
|
||||||
|
|
||||||
|
[[rules]]
|
||||||
|
id = "generic-api-key"
|
||||||
|
[rules.allowlist]
|
||||||
|
paths = [
|
||||||
|
'''src/pages/ChatPage\.tsx'''
|
||||||
|
]
|
||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1 @@
|
|||||||
registry=https://registry.npmmirror.com
|
registry=https://registry.npmjs.org
|
||||||
electron-mirror=https://npmmirror.com/mirrors/electron/
|
|
||||||
electron-builder-binaries-mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
</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" alt="Issues">
|
||||||
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
|
</a>
|
||||||
|
<a href="https://github.com/hicccc77/WeFlow/releases">
|
||||||
|
<img src="https://img.shields.io/github/downloads/hicccc77/WeFlow/total?style=flat-square" alt="Downloads" />
|
||||||
</a>
|
</a>
|
||||||
<a href="https://t.me/weflow_cc">
|
<a href="https://t.me/weflow_cc">
|
||||||
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||||
@@ -50,13 +52,15 @@ 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) | `.deb`、`.tar.gz` |
|
| Linux | x64 设备(amd64) | `.AppImage`、`.tar.gz` |
|
||||||
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
若你只想使用成品版本,可前往 [Releases](https://github.com/hicccc77/WeFlow/releases) 下载并安装。
|
||||||
|
|
||||||
|
> ArchLinux 用户可以选择 `yay -S weflow` 快速安装
|
||||||
|
|
||||||
## 详细功能清单
|
## 详细功能清单
|
||||||
|
|
||||||
当前版本已支持以下能力:
|
当前版本已支持以下能力:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# WeFlow HTTP API / Push 文档
|
# WeFlow HTTP API / Push 文档
|
||||||
|
|
||||||
WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
WeFlow 提供本地 HTTP API(已支持GET 和 POST请求),便于外部脚本或工具读取聊天记录、会话、联系人、群成员和导出的媒体文件;也支持在检测到新消息后通过固定 SSE 地址主动推送消息事件。
|
||||||
|
|
||||||
## 启用方式
|
## 启用方式
|
||||||
|
|
||||||
@@ -11,17 +11,27 @@ WeFlow 提供本地 HTTP API,便于外部脚本或工具读取聊天记录、
|
|||||||
- 基础地址:`http://127.0.0.1:5031`
|
- 基础地址:`http://127.0.0.1:5031`
|
||||||
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
- 可选开启 `主动推送`,检测到新收到的消息后会通过 `GET /api/v1/push/messages` 推送给 SSE 订阅端
|
||||||
|
|
||||||
|
**状态记忆**:API 服务和主动推送的状态及端口会自动保存,重启 WeFlow 后会自动恢复运行。
|
||||||
|
|
||||||
|
## 鉴权规范
|
||||||
|
|
||||||
|
**鉴权规范 (Access Token)** 除健康检查接口外,所有 `/api/v1/*` 接口均受 Token 保护。支持三种传参方式(任选其一):
|
||||||
|
|
||||||
|
1. **HTTP Header (推荐)**: `Authorization: Bearer <您的Token>`
|
||||||
|
2. **Query 参数**: `?access_token=<您的Token>`(SSE 长连接推荐此方式)
|
||||||
|
3. **JSON Body**: `{"access_token": "<您的Token>"}`(仅限 POST 请求)
|
||||||
|
|
||||||
## 接口列表
|
## 接口列表
|
||||||
|
|
||||||
- `GET /health`
|
- `GET|POST /health`
|
||||||
- `GET /api/v1/health`
|
- `GET|POST /api/v1/health`
|
||||||
- `GET /api/v1/push/messages`
|
- `GET|POST /api/v1/push/messages`
|
||||||
- `GET /api/v1/messages`
|
- `GET|POST /api/v1/messages`
|
||||||
- `GET /api/v1/messages/new`
|
- `GET|POST /api/v1/messages/new`
|
||||||
- `GET /api/v1/sessions`
|
- `GET|POST /api/v1/sessions`
|
||||||
- `GET /api/v1/contacts`
|
- `GET|POST /api/v1/contacts`
|
||||||
- `GET /api/v1/group-members`
|
- `GET|POST /api/v1/group-members`
|
||||||
- `GET /api/v1/media/*`
|
- `GET|POST /api/v1/media/*`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,7 +90,7 @@ GET /api/v1/push/messages
|
|||||||
### 示例
|
### 示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -N "http://127.0.0.1:5031/api/v1/push/messages"
|
curl -N "http://127.0.0.1:5031/api/v1/push/messages?access_token=YOUR_TOKEN
|
||||||
```
|
```
|
||||||
|
|
||||||
示例事件:
|
示例事件:
|
||||||
@@ -94,6 +104,8 @@ data: {"event":"message.new","sessionId":"xxx@chatroom","messageKey":"server:123
|
|||||||
|
|
||||||
## 3. 获取消息
|
## 3. 获取消息
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
读取指定会话的消息,支持原始 JSON 和 ChatLab 格式。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -231,6 +243,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
|
|||||||
|
|
||||||
## 4. 获取会话列表
|
## 4. 获取会话列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -276,6 +290,8 @@ GET /api/v1/sessions
|
|||||||
|
|
||||||
## 5. 获取联系人列表
|
## 5. 获取联系人列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -325,6 +341,8 @@ GET /api/v1/contacts
|
|||||||
|
|
||||||
## 6. 获取群成员列表
|
## 6. 获取群成员列表
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
返回群成员的 `wxid`、群昵称、备注、微信号等信息。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -417,6 +435,8 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&include
|
|||||||
|
|
||||||
## 7. 访问导出媒体
|
## 7. 访问导出媒体
|
||||||
|
|
||||||
|
> 当使用 POST 时,请将参数放在 JSON Body 中(Content-Type: application/json)
|
||||||
|
|
||||||
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
通过消息接口启用 `media=1` 后,接口会先把图片、语音、视频、表情导出到本地缓存目录,再返回可访问的 HTTP 地址。
|
||||||
|
|
||||||
**请求**
|
**请求**
|
||||||
@@ -461,19 +481,23 @@ curl "http://127.0.0.1:5031/api/v1/media/xxx@chatroom/emojis/emoji_300.gif"
|
|||||||
### PowerShell
|
### PowerShell
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/health
|
$headers = @{ "Authorization" = "Bearer YOUR_TOKEN" }
|
||||||
Invoke-RestMethod http://127.0.0.1:5031/api/v1/sessions
|
$body = @{ talker = "wxid_xxx"; limit = 10 } | ConvertTo-Json
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&limit=10"
|
|
||||||
Invoke-RestMethod "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom&includeMessageCounts=1"
|
Invoke-RestMethod -Uri "http://127.0.0.1:5031/api/v1/messages" -Method POST -Headers $headers -Body $body -ContentType "application/json"
|
||||||
```
|
```
|
||||||
|
|
||||||
### cURL
|
### cURL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:5031/health
|
# GET 带 Token Header
|
||||||
curl "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1"
|
curl -H "Authorization: Bearer YOUR_TOKEN" "http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx"
|
||||||
curl "http://127.0.0.1:5031/api/v1/contacts?keyword=张三"
|
|
||||||
curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
# POST 带 JSON Body
|
||||||
|
curl -X POST http://127.0.0.1:5031/api/v1/messages \
|
||||||
|
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"talker": "xxx@chatroom", "chatlab": true}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Python
|
### Python
|
||||||
@@ -482,19 +506,21 @@ curl "http://127.0.0.1:5031/api/v1/group-members?chatroomId=xxx@chatroom"
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = "http://127.0.0.1:5031"
|
BASE_URL = "http://127.0.0.1:5031"
|
||||||
|
headers = {"Authorization": "Bearer YOUR_TOKEN", "Content-Type": "application/json"}
|
||||||
|
|
||||||
messages = requests.get(
|
# POST 方式获取消息
|
||||||
|
messages = requests.post(
|
||||||
f"{BASE_URL}/api/v1/messages",
|
f"{BASE_URL}/api/v1/messages",
|
||||||
params={"talker": "xxx@chatroom", "limit": 50}
|
json={"talker": "xxx@chatroom", "limit": 50},
|
||||||
|
headers=headers
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
|
# GET 方式获取群成员
|
||||||
members = requests.get(
|
members = requests.get(
|
||||||
f"{BASE_URL}/api/v1/group-members",
|
f"{BASE_URL}/api/v1/group-members",
|
||||||
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1}
|
params={"chatroomId": "xxx@chatroom", "includeMessageCounts": 1},
|
||||||
|
headers=headers
|
||||||
).json()
|
).json()
|
||||||
|
|
||||||
print(messages)
|
|
||||||
print(members)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
247
electron/main.ts
247
electron/main.ts
@@ -30,16 +30,126 @@ import { cloudControlService } from './services/cloudControlService'
|
|||||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||||
import { httpService } from './services/httpService'
|
import { httpService } from './services/httpService'
|
||||||
import { messagePushService } from './services/messagePushService'
|
import { messagePushService } from './services/messagePushService'
|
||||||
|
import { bizService } from './services/bizService'
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制全量下载
|
||||||
// Windows x64 与 arm64 使用不同更新通道,避免 latest.yml 互相覆盖导致下错架构安装包。
|
// 更新通道策略:
|
||||||
if (process.platform === 'win32' && process.arch === 'arm64') {
|
// - 稳定版(如 4.3.0)默认走 latest
|
||||||
autoUpdater.channel = 'latest-arm64'
|
// - 预览版(如 0.26.2)默认走 preview(0.年.当年发布序号)
|
||||||
|
// - 开发版(如 26.4.5)默认走 dev(年.月.日)
|
||||||
|
// - 用户可在设置页切换稳定/预览/开发,切换后即时生效
|
||||||
|
// 同时区分 Windows x64 / arm64,避免更新清单互相覆盖。
|
||||||
|
const appVersion = app.getVersion()
|
||||||
|
const inferUpdateTrackFromVersion = (version: string): 'stable' | 'preview' | 'dev' => {
|
||||||
|
const normalized = String(version || '').trim().replace(/^v/i, '')
|
||||||
|
if (/^0\.\d{2}\.\d+$/i.test(normalized)) return 'preview'
|
||||||
|
if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(normalized)) return 'dev'
|
||||||
|
// 兼容旧版命名(如 4.3.0-preview.26.1 / 4.3.0-dev.26.3.4)
|
||||||
|
if (/-preview\.\d+\.\d+$/i.test(normalized)) return 'preview'
|
||||||
|
if (/-dev\.\d+\.\d+\.\d+$/i.test(normalized)) return 'dev'
|
||||||
|
// 兼容 alpha/beta/rc 预发布
|
||||||
|
if (/(alpha|beta|rc)/i.test(normalized)) return 'dev'
|
||||||
|
return 'stable'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultUpdateTrack: 'stable' | 'preview' | 'dev' = (() => {
|
||||||
|
const inferred = inferUpdateTrackFromVersion(appVersion)
|
||||||
|
if (inferred === 'preview' || inferred === 'dev') return inferred
|
||||||
|
return 'stable'
|
||||||
|
})()
|
||||||
|
let configService: ConfigService | null = null
|
||||||
|
|
||||||
|
const normalizeUpdateTrack = (raw: unknown): 'stable' | 'preview' | 'dev' | null => {
|
||||||
|
if (raw === 'stable' || raw === 'preview' || raw === 'dev') return raw
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEffectiveUpdateTrack = (): 'stable' | 'preview' | 'dev' => {
|
||||||
|
const configuredTrack = normalizeUpdateTrack(configService?.get('updateChannel'))
|
||||||
|
return configuredTrack || defaultUpdateTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRemoteVersionNewer = (latestVersion: string, currentVersion: string): boolean => {
|
||||||
|
const latest = String(latestVersion || '').trim()
|
||||||
|
const current = String(currentVersion || '').trim()
|
||||||
|
if (!latest || !current) return false
|
||||||
|
|
||||||
|
const parseVersion = (version: string) => {
|
||||||
|
const normalized = version.replace(/^v/i, '')
|
||||||
|
const [main, pre = ''] = normalized.split('-', 2)
|
||||||
|
const core = main.split('.').map((segment) => Number.parseInt(segment, 10) || 0)
|
||||||
|
const prerelease = pre ? pre.split('.').map((segment) => /^\d+$/.test(segment) ? Number.parseInt(segment, 10) : segment) : []
|
||||||
|
return { core, prerelease }
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareParsedVersion = (a: ReturnType<typeof parseVersion>, b: ReturnType<typeof parseVersion>): number => {
|
||||||
|
const maxLen = Math.max(a.core.length, b.core.length)
|
||||||
|
for (let i = 0; i < maxLen; i += 1) {
|
||||||
|
const left = a.core[i] || 0
|
||||||
|
const right = b.core[i] || 0
|
||||||
|
if (left > right) return 1
|
||||||
|
if (left < right) return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aPre = a.prerelease
|
||||||
|
const bPre = b.prerelease
|
||||||
|
if (aPre.length === 0 && bPre.length === 0) return 0
|
||||||
|
if (aPre.length === 0) return 1
|
||||||
|
if (bPre.length === 0) return -1
|
||||||
|
|
||||||
|
const preMaxLen = Math.max(aPre.length, bPre.length)
|
||||||
|
for (let i = 0; i < preMaxLen; i += 1) {
|
||||||
|
const left = aPre[i]
|
||||||
|
const right = bPre[i]
|
||||||
|
if (left === undefined) return -1
|
||||||
|
if (right === undefined) return 1
|
||||||
|
if (left === right) continue
|
||||||
|
|
||||||
|
const leftNum = typeof left === 'number'
|
||||||
|
const rightNum = typeof right === 'number'
|
||||||
|
if (leftNum && rightNum) return left > right ? 1 : -1
|
||||||
|
if (leftNum) return -1
|
||||||
|
if (rightNum) return 1
|
||||||
|
return String(left) > String(right) ? 1 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return autoUpdater.currentVersion.compare(latest) < 0
|
||||||
|
} catch {
|
||||||
|
return compareParsedVersion(parseVersion(latest), parseVersion(current)) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldOfferUpdateForTrack = (latestVersion: string, currentVersion: string): boolean => {
|
||||||
|
if (isRemoteVersionNewer(latestVersion, currentVersion)) return true
|
||||||
|
const effectiveTrack = getEffectiveUpdateTrack()
|
||||||
|
const currentTrack = inferUpdateTrackFromVersion(currentVersion)
|
||||||
|
// 切换通道后,目标通道最新版本与当前版本不同即提示更新(即使是降级)
|
||||||
|
if (effectiveTrack !== currentTrack && latestVersion !== currentVersion) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyAutoUpdateChannel = (reason: 'startup' | 'settings' = 'startup') => {
|
||||||
|
const track = getEffectiveUpdateTrack()
|
||||||
|
const currentTrack = inferUpdateTrackFromVersion(appVersion)
|
||||||
|
const baseUpdateChannel = track === 'stable' ? 'latest' : track
|
||||||
|
autoUpdater.allowPrerelease = track !== 'stable'
|
||||||
|
// 只要用户当前选择的目标通道与当前安装版本所属通道不同,就允许跨通道更新(含降级)
|
||||||
|
autoUpdater.allowDowngrade = track !== currentTrack
|
||||||
|
autoUpdater.channel =
|
||||||
|
process.platform === 'win32' && process.arch === 'arm64'
|
||||||
|
? `${baseUpdateChannel}-arm64`
|
||||||
|
: baseUpdateChannel
|
||||||
|
console.log(`[Update](${reason}) 当前版本 ${appVersion},当前轨道: ${currentTrack},渠道偏好: ${track},更新通道: ${autoUpdater.channel},allowDowngrade=${autoUpdater.allowDowngrade}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAutoUpdateChannel('startup')
|
||||||
const AUTO_UPDATE_ENABLED =
|
const AUTO_UPDATE_ENABLED =
|
||||||
process.env.AUTO_UPDATE_ENABLED === 'true' ||
|
process.env.AUTO_UPDATE_ENABLED === 'true' ||
|
||||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||||
@@ -87,7 +197,6 @@ function sanitizePathEnv() {
|
|||||||
sanitizePathEnv()
|
sanitizePathEnv()
|
||||||
|
|
||||||
// 单例服务
|
// 单例服务
|
||||||
let configService: ConfigService | null = null
|
|
||||||
|
|
||||||
// 协议窗口实例
|
// 协议窗口实例
|
||||||
let agreementWindow: BrowserWindow | null = null
|
let agreementWindow: BrowserWindow | null = null
|
||||||
@@ -146,27 +255,67 @@ const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
|
|||||||
|
|
||||||
if (!merged.trim()) return ''
|
if (!merged.trim()) return ''
|
||||||
|
|
||||||
const shouldStripReleaseSection = (headingRaw: string): boolean => {
|
const normalizeHeadingText = (raw: string): string => {
|
||||||
const heading = headingRaw.trim().toLowerCase()
|
return raw
|
||||||
if (heading === '下载' || heading === 'download') return true
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, '\'')
|
||||||
|
.replace(/'/gi, '\'')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[::]/g, '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
const compactHeading = heading.replace(/\s+/g, '')
|
const shouldStripReleaseSection = (headingRaw: string): boolean => {
|
||||||
if (compactHeading.startsWith('macos安装提示')) return true
|
const heading = normalizeHeadingText(headingRaw)
|
||||||
if (compactHeading.startsWith('mac安装提示')) return true
|
if (!heading) return false
|
||||||
|
if (heading.startsWith('下载') || heading.startsWith('download')) return true
|
||||||
|
|
||||||
|
if ((heading.includes('macos') || heading.startsWith('mac')) && heading.includes('安装提示')) return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容 electron-updater 直接返回 HTML 的场景
|
// 兼容 electron-updater 直接返回 HTML 的场景(含 dir/anchor 等标签嵌套)
|
||||||
const removeDownloadSectionFromHtml = (input: string): string => {
|
const removeDownloadSectionFromHtml = (input: string): string => {
|
||||||
return input
|
const headingPattern = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi
|
||||||
.replace(
|
const headings: Array<{ start: number; end: number; headingText: string }> = []
|
||||||
/<h[1-6][^>]*>\s*(?:下载|download)\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
|
let match: RegExpExecArray | null
|
||||||
''
|
|
||||||
)
|
while ((match = headingPattern.exec(input)) !== null) {
|
||||||
.replace(
|
const full = match[0]
|
||||||
/<h[1-6][^>]*>\s*(?:mac\s*os|mac)\s*安装提示(?:\s*[((]\s*未知来源\s*[))])?\s*<\/h[1-6]>\s*[\s\S]*?(?=<h[1-6]\b|$)/gi,
|
headings.push({
|
||||||
''
|
start: match.index,
|
||||||
)
|
end: match.index + full.length,
|
||||||
|
headingText: match[2] || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headings.length === 0) return input
|
||||||
|
|
||||||
|
const rangesToRemove: Array<{ start: number; end: number }> = []
|
||||||
|
for (let i = 0; i < headings.length; i += 1) {
|
||||||
|
const current = headings[i]
|
||||||
|
if (!shouldStripReleaseSection(current.headingText)) continue
|
||||||
|
|
||||||
|
const nextStart = i + 1 < headings.length ? headings[i + 1].start : input.length
|
||||||
|
rangesToRemove.push({ start: current.start, end: nextStart })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangesToRemove.length === 0) return input
|
||||||
|
|
||||||
|
let output = ''
|
||||||
|
let cursor = 0
|
||||||
|
for (const range of rangesToRemove) {
|
||||||
|
output += input.slice(cursor, range.start)
|
||||||
|
cursor = range.end
|
||||||
|
}
|
||||||
|
output += input.slice(cursor)
|
||||||
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容 Markdown 场景(Action 最终 release note 模板)
|
// 兼容 Markdown 场景(Action 最终 release note 模板)
|
||||||
@@ -195,12 +344,22 @@ const normalizeReleaseNotes = (rawReleaseNotes: unknown): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged))
|
const cleaned = removeDownloadSectionFromMarkdown(removeDownloadSectionFromHtml(merged))
|
||||||
|
// 兜底:即使没有匹配到标题,也不在弹窗展示 macOS 隔离标记清理命令
|
||||||
|
.replace(/^[ \t>*-]*`?\s*xattr\s+-[a-z]*d[a-z]*\s+com\.apple\.quarantine[^\n]*`?\s*$/gim, '')
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getDialogReleaseNotes = (rawReleaseNotes: unknown): string => {
|
||||||
|
const track = getEffectiveUpdateTrack()
|
||||||
|
if (track !== 'stable') {
|
||||||
|
return '修复了一些已知问题'
|
||||||
|
}
|
||||||
|
return normalizeReleaseNotes(rawReleaseNotes)
|
||||||
|
}
|
||||||
|
|
||||||
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
|
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
|
||||||
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
|
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
|
||||||
|
|
||||||
@@ -1068,6 +1227,7 @@ const removeMatchedEntriesInDir = async (
|
|||||||
// 注册 IPC 处理器
|
// 注册 IPC 处理器
|
||||||
function registerIpcHandlers() {
|
function registerIpcHandlers() {
|
||||||
registerNotificationHandlers()
|
registerNotificationHandlers()
|
||||||
|
bizService.registerHandlers()
|
||||||
// 配置相关
|
// 配置相关
|
||||||
ipcMain.handle('config:get', async (_, key: string) => {
|
ipcMain.handle('config:get', async (_, key: string) => {
|
||||||
return configService?.get(key as any)
|
return configService?.get(key as any)
|
||||||
@@ -1075,6 +1235,9 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
ipcMain.handle('config:set', async (_, key: string, value: any) => {
|
||||||
const result = configService?.set(key as any, value)
|
const result = configService?.set(key as any, value)
|
||||||
|
if (key === 'updateChannel') {
|
||||||
|
applyAutoUpdateChannel('settings')
|
||||||
|
}
|
||||||
void messagePushService.handleConfigChanged(key)
|
void messagePushService.handleConfigChanged(key)
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
@@ -1196,11 +1359,12 @@ function registerIpcHandlers() {
|
|||||||
if (result && result.updateInfo) {
|
if (result && result.updateInfo) {
|
||||||
const currentVersion = app.getVersion()
|
const currentVersion = app.getVersion()
|
||||||
const latestVersion = result.updateInfo.version
|
const latestVersion = result.updateInfo.version
|
||||||
if (latestVersion !== currentVersion) {
|
if (shouldOfferUpdateForTrack(latestVersion, currentVersion)) {
|
||||||
return {
|
return {
|
||||||
hasUpdate: true,
|
hasUpdate: true,
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
|
releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
|
||||||
|
minimumVersion: (result.updateInfo as any).minimumVersion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1258,7 +1422,7 @@ function registerIpcHandlers() {
|
|||||||
try {
|
try {
|
||||||
console.log('[Update] 开始下载更新...')
|
console.log('[Update] 开始下载更新...')
|
||||||
await autoUpdater.downloadUpdate()
|
await autoUpdater.downloadUpdate()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[Update] 下载更新失败:', error)
|
console.error('[Update] 下载更新失败:', error)
|
||||||
// 失败时清理状态和监听器
|
// 失败时清理状态和监听器
|
||||||
isDownloadInProgress = false
|
isDownloadInProgress = false
|
||||||
@@ -1270,7 +1434,10 @@ function registerIpcHandlers() {
|
|||||||
autoUpdater.removeListener('update-downloaded', downloadedHandler)
|
autoUpdater.removeListener('update-downloaded', downloadedHandler)
|
||||||
downloadedHandler = null
|
downloadedHandler = null
|
||||||
}
|
}
|
||||||
throw error
|
|
||||||
|
// 统一错误提示格式,避免出现 [object Object] 的 JSON 字符串
|
||||||
|
const errorMessage = error.message || (typeof error === 'string' ? error : JSON.stringify(error))
|
||||||
|
throw new Error(errorMessage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1545,8 +1712,8 @@ function registerIpcHandlers() {
|
|||||||
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
return await chatService.resolveTransferDisplayNames(chatroomId, payerUsername, receiverUsername)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getContacts', async () => {
|
ipcMain.handle('chat:getContacts', async (_, options?: { lite?: boolean }) => {
|
||||||
return await chatService.getContacts()
|
return await chatService.getContacts(options)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||||
@@ -2157,6 +2324,13 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
'groupAnalytics:getGroupMemberAnalytics',
|
||||||
|
async (_, chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => {
|
||||||
|
return groupAnalyticsService.getGroupMemberAnalytics(chatroomId, memberUsername, startTime, endTime)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'groupAnalytics:getGroupMemberMessages',
|
'groupAnalytics:getGroupMemberMessages',
|
||||||
async (
|
async (
|
||||||
@@ -2587,26 +2761,27 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
// 密钥获取
|
// 密钥获取
|
||||||
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
ipcMain.handle('key:autoGetDbKey', async (event) => {
|
||||||
return keyService.autoGetDbKey(180_000, (message, level) => {
|
return keyService.autoGetDbKey(180_000, (message: string, level: number) => {
|
||||||
event.sender.send('key:dbKeyStatus', { message, level })
|
event.sender.send('key:dbKeyStatus', { message, level })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
|
ipcMain.handle('key:autoGetImageKey', async (event, manualDir?: string, wxid?: string) => {
|
||||||
return keyService.autoGetImageKey(manualDir, (message) => {
|
return keyService.autoGetImageKey(manualDir, (message: string) => {
|
||||||
event.sender.send('key:imageKeyStatus', { message })
|
event.sender.send('key:imageKeyStatus', { message })
|
||||||
}, wxid)
|
}, wxid)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
|
ipcMain.handle('key:scanImageKeyFromMemory', async (event, userDir: string) => {
|
||||||
return keyService.autoGetImageKeyByMemoryScan(userDir, (message) => {
|
return keyService.autoGetImageKeyByMemoryScan(userDir, (message: string) => {
|
||||||
event.sender.send('key:imageKeyStatus', { message })
|
event.sender.send('key:imageKeyStatus', { message })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
ipcMain.handle('http:start', async (_, port?: number) => {
|
ipcMain.handle('http:start', async (_, port?: number, host?: string) => {
|
||||||
return httpService.start(port || 5031)
|
const bindHost = typeof host === 'string' && host.trim() ? host.trim() : '127.0.0.1'
|
||||||
|
return httpService.start(port || 5031, bindHost)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('http:stop', async () => {
|
ipcMain.handle('http:stop', async () => {
|
||||||
@@ -2642,7 +2817,7 @@ function checkForUpdatesOnStartup() {
|
|||||||
const latestVersion = result.updateInfo.version
|
const latestVersion = result.updateInfo.version
|
||||||
|
|
||||||
// 检查是否有新版本
|
// 检查是否有新版本
|
||||||
if (latestVersion !== currentVersion && mainWindow) {
|
if (shouldOfferUpdateForTrack(latestVersion, currentVersion) && mainWindow) {
|
||||||
// 检查该版本是否被用户忽略
|
// 检查该版本是否被用户忽略
|
||||||
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
const ignoredVersion = configService?.get('ignoredUpdateVersion')
|
||||||
if (ignoredVersion === latestVersion) {
|
if (ignoredVersion === latestVersion) {
|
||||||
@@ -2653,7 +2828,8 @@ function checkForUpdatesOnStartup() {
|
|||||||
// 通知渲染进程有新版本
|
// 通知渲染进程有新版本
|
||||||
mainWindow.webContents.send('app:updateAvailable', {
|
mainWindow.webContents.send('app:updateAvailable', {
|
||||||
version: latestVersion,
|
version: latestVersion,
|
||||||
releaseNotes: normalizeReleaseNotes(result.updateInfo.releaseNotes)
|
releaseNotes: getDialogReleaseNotes(result.updateInfo.releaseNotes),
|
||||||
|
minimumVersion: (result.updateInfo as any).minimumVersion
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2686,6 +2862,7 @@ app.whenReady().then(async () => {
|
|||||||
// 初始化配置服务
|
// 初始化配置服务
|
||||||
updateSplashProgress(5, '正在加载配置...')
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
|
applyAutoUpdateChannel('startup')
|
||||||
|
|
||||||
// 将用户主题配置推送给 Splash 窗口
|
// 将用户主题配置推送给 Splash 窗口
|
||||||
if (splashWindow && !splashWindow.isDestroyed()) {
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
@@ -2825,6 +3002,8 @@ app.whenReady().then(async () => {
|
|||||||
// 启动时检测更新(不阻塞启动)
|
// 启动时检测更新(不阻塞启动)
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
|
await httpService.autoStart()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createWindow()
|
mainWindow = createWindow()
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||||
},
|
},
|
||||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
getContacts: (options?: { lite?: boolean }) => ipcRenderer.invoke('chat:getContacts', options),
|
||||||
getMessage: (sessionId: string, localId: number) =>
|
getMessage: (sessionId: string, localId: number) =>
|
||||||
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
ipcRenderer.invoke('chat:getMessage', sessionId, localId),
|
||||||
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
|
||||||
@@ -297,6 +297,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMemberAnalytics', chatroomId, memberUsername, startTime, endTime),
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
@@ -412,6 +413,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
biz: {
|
||||||
|
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||||
|
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||||
|
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||||
|
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||||
|
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// 数据收集
|
// 数据收集
|
||||||
cloud: {
|
cloud: {
|
||||||
@@ -422,7 +431,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// HTTP API 服务
|
// HTTP API 服务
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => ipcRenderer.invoke('http:start', port),
|
start: (port?: number, host?: string) => ipcRenderer.invoke('http:start', port, host),
|
||||||
stop: () => ipcRenderer.invoke('http:stop'),
|
stop: () => ipcRenderer.invoke('http:stop'),
|
||||||
status: () => ipcRenderer.invoke('http:status')
|
status: () => ipcRenderer.invoke('http:status')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1135,7 +1135,7 @@ class AnnualReportService {
|
|||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastProgressAt > 200) {
|
if (now - lastProgressAt > 200) {
|
||||||
let progress = 30
|
let progress: number
|
||||||
if (totalMessagesForProgress > 0) {
|
if (totalMessagesForProgress > 0) {
|
||||||
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
const ratio = Math.min(1, processedMessages / totalMessagesForProgress)
|
||||||
progress = 30 + Math.floor(ratio * 50)
|
progress = 30 + Math.floor(ratio * 50)
|
||||||
|
|||||||
221
electron/services/bizService.ts
Normal file
221
electron/services/bizService.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { join } from 'path'
|
||||||
|
import { readdirSync, existsSync } from 'fs'
|
||||||
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
import { chatService, Message } from './chatService'
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
export interface BizAccount {
|
||||||
|
username: string
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
type: number
|
||||||
|
last_time: number
|
||||||
|
formatted_last_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BizMessage {
|
||||||
|
local_id: number
|
||||||
|
create_time: number
|
||||||
|
title: string
|
||||||
|
des: string
|
||||||
|
url: string
|
||||||
|
cover: string
|
||||||
|
content_list: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BizPayRecord {
|
||||||
|
local_id: number
|
||||||
|
create_time: number
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
merchant_name: string
|
||||||
|
merchant_icon: string
|
||||||
|
timestamp: number
|
||||||
|
formatted_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BizService {
|
||||||
|
private configService: ConfigService
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.configService = new ConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractXmlValue(xml: string, tagName: string): string {
|
||||||
|
const regex = new RegExp(`<${tagName}>([\\s\\S]*?)</${tagName}>`, 'i')
|
||||||
|
const match = regex.exec(xml)
|
||||||
|
if (match) {
|
||||||
|
return match[1].replace(/<!\[CDATA\[/g, '').replace(/\]\]>/g, '').trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBizContentList(xmlStr: string): any[] {
|
||||||
|
if (!xmlStr) return []
|
||||||
|
const contentList: any[] = []
|
||||||
|
try {
|
||||||
|
const itemRegex = /<item>([\s\S]*?)<\/item>/gi
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = itemRegex.exec(xmlStr)) !== null) {
|
||||||
|
const itemXml = match[1]
|
||||||
|
const itemStruct = {
|
||||||
|
title: this.extractXmlValue(itemXml, 'title'),
|
||||||
|
url: this.extractXmlValue(itemXml, 'url'),
|
||||||
|
cover: this.extractXmlValue(itemXml, 'cover') || this.extractXmlValue(itemXml, 'thumburl'),
|
||||||
|
summary: this.extractXmlValue(itemXml, 'summary') || this.extractXmlValue(itemXml, 'digest')
|
||||||
|
}
|
||||||
|
if (itemStruct.title) contentList.push(itemStruct)
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
return contentList
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePayXml(xmlStr: string): any {
|
||||||
|
if (!xmlStr) return null
|
||||||
|
try {
|
||||||
|
const title = this.extractXmlValue(xmlStr, 'title')
|
||||||
|
const description = this.extractXmlValue(xmlStr, 'des')
|
||||||
|
const merchantName = this.extractXmlValue(xmlStr, 'display_name') || '微信支付'
|
||||||
|
const merchantIcon = this.extractXmlValue(xmlStr, 'icon_url')
|
||||||
|
const pubTime = parseInt(this.extractXmlValue(xmlStr, 'pub_time') || '0')
|
||||||
|
if (!title && !description) return null
|
||||||
|
return { title, description, merchant_name: merchantName, merchant_icon: merchantIcon, timestamp: pubTime }
|
||||||
|
} catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||||
|
try {
|
||||||
|
const contactsResult = await chatService.getContacts({ lite: true })
|
||||||
|
if (!contactsResult.success || !contactsResult.contacts) return []
|
||||||
|
|
||||||
|
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
|
||||||
|
const usernames = officialContacts.map(c => c.username)
|
||||||
|
const enrichment = await chatService.enrichSessionsContactInfo(usernames)
|
||||||
|
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
|
||||||
|
|
||||||
|
const root = this.configService.get('dbPath')
|
||||||
|
const myWxid = this.configService.get('myWxid')
|
||||||
|
const accountWxid = account || myWxid
|
||||||
|
if (!root || !accountWxid) return []
|
||||||
|
|
||||||
|
const dbDir = join(root, accountWxid, 'db_storage', 'message')
|
||||||
|
const bizLatestTime: Record<string, number> = {}
|
||||||
|
|
||||||
|
if (existsSync(dbDir)) {
|
||||||
|
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
||||||
|
for (const file of bizDbFiles) {
|
||||||
|
const dbPath = join(dbDir, file)
|
||||||
|
const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id')
|
||||||
|
if (name2idRes.success && name2idRes.rows) {
|
||||||
|
for (const row of name2idRes.rows) {
|
||||||
|
const uname = row.username || row.user_name
|
||||||
|
if (uname) {
|
||||||
|
const md5 = createHash('md5').update(uname).digest('hex').toLowerCase()
|
||||||
|
const tName = `Msg_${md5}`
|
||||||
|
const timeRes = await wcdbService.execQuery('message', dbPath, `SELECT MAX(create_time) as max_time FROM ${tName}`)
|
||||||
|
if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) {
|
||||||
|
const t = parseInt(timeRes.rows[0].max_time)
|
||||||
|
if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: BizAccount[] = officialContacts.map(contact => {
|
||||||
|
const uname = contact.username
|
||||||
|
const info = contactInfoMap[uname]
|
||||||
|
const lastTime = bizLatestTime[uname] || 0
|
||||||
|
return {
|
||||||
|
username: uname,
|
||||||
|
name: info?.displayName || contact.displayName || uname,
|
||||||
|
avatar: info?.avatarUrl || '',
|
||||||
|
type: 0,
|
||||||
|
last_time: lastTime,
|
||||||
|
formatted_last_time: lastTime ? new Date(lastTime * 1000).toISOString().split('T')[0] : ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const contactDbPath = join(root, accountWxid, 'contact.db')
|
||||||
|
if (existsSync(contactDbPath)) {
|
||||||
|
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
|
||||||
|
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||||
|
const typeMap: Record<string, number> = {}
|
||||||
|
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
|
||||||
|
for (const acc of result) if (typeMap[acc.username] !== undefined) acc.type = typeMap[acc.username]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
.filter(acc => !acc.name.includes('朋友圈广告'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.username === 'gh_3dfda90e39d6') return -1
|
||||||
|
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||||
|
return b.last_time - a.last_time
|
||||||
|
})
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||||
|
try {
|
||||||
|
// 仅保留核心路径:利用 chatService 的自动路由能力
|
||||||
|
const res = await chatService.getMessages(username, offset, limit)
|
||||||
|
if (!res.success || !res.messages) return []
|
||||||
|
|
||||||
|
return res.messages.map(msg => {
|
||||||
|
const bizMsg: BizMessage = {
|
||||||
|
local_id: msg.localId,
|
||||||
|
create_time: msg.createTime,
|
||||||
|
title: msg.linkTitle || msg.parsedContent || '',
|
||||||
|
des: msg.appMsgDesc || '',
|
||||||
|
url: msg.linkUrl || '',
|
||||||
|
cover: msg.linkThumb || msg.appMsgThumbUrl || '',
|
||||||
|
content_list: []
|
||||||
|
}
|
||||||
|
if (msg.rawContent) {
|
||||||
|
bizMsg.content_list = this.parseBizContentList(msg.rawContent)
|
||||||
|
if (bizMsg.content_list.length > 0 && !bizMsg.title) {
|
||||||
|
bizMsg.title = bizMsg.content_list[0].title
|
||||||
|
bizMsg.cover = bizMsg.cover || bizMsg.content_list[0].cover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bizMsg
|
||||||
|
})
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||||
|
const username = 'gh_3dfda90e39d6'
|
||||||
|
try {
|
||||||
|
const res = await chatService.getMessages(username, offset, limit)
|
||||||
|
if (!res.success || !res.messages) return []
|
||||||
|
|
||||||
|
const records: BizPayRecord[] = []
|
||||||
|
for (const msg of res.messages) {
|
||||||
|
if (!msg.rawContent) continue
|
||||||
|
const parsedData = this.parsePayXml(msg.rawContent)
|
||||||
|
if (parsedData) {
|
||||||
|
records.push({
|
||||||
|
local_id: msg.localId,
|
||||||
|
create_time: msg.createTime,
|
||||||
|
...parsedData,
|
||||||
|
timestamp: parsedData.timestamp || msg.createTime,
|
||||||
|
formatted_time: new Date((parsedData.timestamp || msg.createTime) * 1000).toLocaleString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHandlers() {
|
||||||
|
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||||
|
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||||
|
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bizService = new BizService()
|
||||||
@@ -16,6 +16,7 @@ import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from '
|
|||||||
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
import { exportCardDiagnosticsService } from './exportCardDiagnosticsService'
|
||||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
import { ImageDecryptService } from './imageDecryptService'
|
import { ImageDecryptService } from './imageDecryptService'
|
||||||
|
import { CONTACT_REGION_LOOKUP_DATA } from './contactRegionLookupData'
|
||||||
import { LRUCache } from '../utils/LRUCache.js'
|
import { LRUCache } from '../utils/LRUCache.js'
|
||||||
|
|
||||||
export interface ChatSession {
|
export interface ChatSession {
|
||||||
@@ -153,10 +154,17 @@ export interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
labels?: string[]
|
||||||
|
detailDescription?: string
|
||||||
|
region?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetContactsOptions {
|
||||||
|
lite?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface ExportSessionStats {
|
interface ExportSessionStats {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
voiceMessages: number
|
voiceMessages: number
|
||||||
@@ -293,6 +301,21 @@ class ChatService {
|
|||||||
private groupMyMessageCountCacheScope = ''
|
private groupMyMessageCountCacheScope = ''
|
||||||
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
|
private groupMyMessageCountMemoryCache = new Map<string, GroupMyMessageCountCacheEntry>()
|
||||||
private initFailureDialogShown = false
|
private initFailureDialogShown = false
|
||||||
|
private readonly contactExtendedFieldCandidates = [
|
||||||
|
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList',
|
||||||
|
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription', 'signature', 'sign',
|
||||||
|
'country', 'province', 'city', 'region',
|
||||||
|
'profile', 'introduction', 'phone', 'mobile', 'telephone', 'tel', 'vcard', 'card_info', 'cardInfo',
|
||||||
|
'extra_buffer', 'extraBuffer'
|
||||||
|
]
|
||||||
|
private readonly contactExtendedFieldCandidateSet = new Set(this.contactExtendedFieldCandidates.map((name) => name.toLowerCase()))
|
||||||
|
private contactExtendedSelectableColumns: string[] | null = null
|
||||||
|
private contactLabelNameMapCache: Map<number, string> | null = null
|
||||||
|
private contactLabelNameMapCacheAt = 0
|
||||||
|
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
|
||||||
|
private readonly contactDisplayNameCollator = new Intl.Collator('zh-CN')
|
||||||
|
private readonly slowGetContactsLogThresholdMs = 1200
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -1267,25 +1290,61 @@ class ChatService {
|
|||||||
/**
|
/**
|
||||||
* 获取通讯录列表
|
* 获取通讯录列表
|
||||||
*/
|
*/
|
||||||
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
async getContacts(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||||
|
const mode: 'lite' | 'full' = options?.lite ? 'lite' : 'full'
|
||||||
|
const inFlight = this.contactsLoadInFlight
|
||||||
|
if (inFlight && (inFlight.mode === mode || (mode === 'lite' && inFlight.mode === 'full'))) {
|
||||||
|
return await inFlight.promise
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.getContactsInternal(options)
|
||||||
|
this.contactsLoadInFlight = { mode, promise }
|
||||||
try {
|
try {
|
||||||
|
return await promise
|
||||||
|
} finally {
|
||||||
|
if (this.contactsLoadInFlight?.promise === promise) {
|
||||||
|
this.contactsLoadInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContactsInternal(options?: GetContactsOptions): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||||
|
const isLiteMode = options?.lite === true
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const stageDurations: Array<{ stage: string; ms: number }> = []
|
||||||
|
const captureStage = (stage: string, stageStartedAt: number) => {
|
||||||
|
stageDurations.push({ stage, ms: Date.now() - stageStartedAt })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connectStartedAt = Date.now()
|
||||||
const connectResult = await this.ensureConnected()
|
const connectResult = await this.ensureConnected()
|
||||||
|
captureStage('ensureConnected', connectStartedAt)
|
||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
return { success: false, error: connectResult.error }
|
return { success: false, error: connectResult.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contactsCompactStartedAt = Date.now()
|
||||||
const contactResult = await wcdbService.getContactsCompact()
|
const contactResult = await wcdbService.getContactsCompact()
|
||||||
|
captureStage('getContactsCompact', contactsCompactStartedAt)
|
||||||
|
|
||||||
if (!contactResult.success || !contactResult.contacts) {
|
if (!contactResult.success || !contactResult.contacts) {
|
||||||
console.error('查询联系人失败:', contactResult.error)
|
console.error('查询联系人失败:', contactResult.error)
|
||||||
return { success: false, error: contactResult.error || '查询联系人失败' }
|
return { success: false, error: contactResult.error || '查询联系人失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rows = contactResult.contacts as Record<string, any>[]
|
||||||
|
if (!isLiteMode) {
|
||||||
|
const hydrateStartedAt = Date.now()
|
||||||
|
rows = await this.hydrateContactsWithExtendedFields(rows)
|
||||||
|
captureStage('hydrateContactsWithExtendedFields', hydrateStartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
const rows = contactResult.contacts as Record<string, any>[]
|
|
||||||
// 获取会话表的最后联系时间用于排序
|
// 获取会话表的最后联系时间用于排序
|
||||||
|
const sessionsStartedAt = Date.now()
|
||||||
const lastContactTimeMap = new Map<string, number>()
|
const lastContactTimeMap = new Map<string, number>()
|
||||||
const sessionResult = await wcdbService.getSessions()
|
const sessionResult = await wcdbService.getSessions()
|
||||||
|
captureStage('getSessions', sessionsStartedAt)
|
||||||
if (sessionResult.success && sessionResult.sessions) {
|
if (sessionResult.success && sessionResult.sessions) {
|
||||||
for (const session of sessionResult.sessions as any[]) {
|
for (const session of sessionResult.sessions as any[]) {
|
||||||
const username = session.username || session.user_name || session.userName || ''
|
const username = session.username || session.user_name || session.userName || ''
|
||||||
@@ -1297,9 +1356,14 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 转换为ContactInfo
|
// 转换为ContactInfo
|
||||||
|
const transformStartedAt = Date.now()
|
||||||
const contacts: (ContactInfo & { lastContactTime: number })[] = []
|
const contacts: (ContactInfo & { lastContactTime: number })[] = []
|
||||||
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
let contactLabelNameMap = new Map<number, string>()
|
||||||
|
if (!isLiteMode) {
|
||||||
|
const labelMapStartedAt = Date.now()
|
||||||
|
contactLabelNameMap = await this.getContactLabelNameMap()
|
||||||
|
captureStage('getContactLabelNameMap', labelMapStartedAt)
|
||||||
|
}
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const username = String(row.username || '').trim()
|
const username = String(row.username || '').trim()
|
||||||
|
|
||||||
@@ -1313,7 +1377,7 @@ class ChatService {
|
|||||||
type = 'group'
|
type = 'group'
|
||||||
} else if (username.startsWith('gh_')) {
|
} else if (username.startsWith('gh_')) {
|
||||||
type = 'official'
|
type = 'official'
|
||||||
} else if (localType === 1 && !excludeNames.has(username)) {
|
} else if (localType === 1 && !FRIEND_EXCLUDE_USERNAMES.has(username)) {
|
||||||
type = 'friend'
|
type = 'friend'
|
||||||
} else if (localType === 0 && quanPin) {
|
} else if (localType === 0 && quanPin) {
|
||||||
type = 'former_friend'
|
type = 'former_friend'
|
||||||
@@ -1322,6 +1386,9 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayName = row.remark || row.nick_name || row.alias || username
|
const displayName = row.remark || row.nick_name || row.alias || username
|
||||||
|
const labels = isLiteMode ? [] : this.parseContactLabels(row, contactLabelNameMap)
|
||||||
|
const detailDescription = isLiteMode ? '' : this.getContactSignature(row)
|
||||||
|
const region = isLiteMode ? '' : this.getContactRegion(row)
|
||||||
|
|
||||||
contacts.push({
|
contacts.push({
|
||||||
username,
|
username,
|
||||||
@@ -1329,16 +1396,19 @@ class ChatService {
|
|||||||
remark: row.remark || undefined,
|
remark: row.remark || undefined,
|
||||||
nickname: row.nick_name || undefined,
|
nickname: row.nick_name || undefined,
|
||||||
alias: row.alias || undefined,
|
alias: row.alias || undefined,
|
||||||
|
labels: labels.length > 0 ? labels : undefined,
|
||||||
|
detailDescription: detailDescription || undefined,
|
||||||
|
region: region || undefined,
|
||||||
avatarUrl: undefined,
|
avatarUrl: undefined,
|
||||||
type,
|
type,
|
||||||
lastContactTime: lastContactTimeMap.get(username) || 0
|
lastContactTime: lastContactTimeMap.get(username) || 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
captureStage('transformContacts', transformStartedAt)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 按最近联系时间排序
|
// 按最近联系时间排序
|
||||||
|
const sortStartedAt = Date.now()
|
||||||
contacts.sort((a, b) => {
|
contacts.sort((a, b) => {
|
||||||
const timeA = a.lastContactTime || 0
|
const timeA = a.lastContactTime || 0
|
||||||
const timeB = b.lastContactTime || 0
|
const timeB = b.lastContactTime || 0
|
||||||
@@ -1347,13 +1417,22 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
if (timeA && !timeB) return -1
|
if (timeA && !timeB) return -1
|
||||||
if (!timeA && timeB) return 1
|
if (!timeA && timeB) return 1
|
||||||
return a.displayName.localeCompare(b.displayName, 'zh-CN')
|
return this.contactDisplayNameCollator.compare(a.displayName, b.displayName)
|
||||||
})
|
})
|
||||||
|
captureStage('sortContacts', sortStartedAt)
|
||||||
|
|
||||||
// 移除临时的lastContactTime字段
|
// 移除临时的lastContactTime字段
|
||||||
|
const finalizeStartedAt = Date.now()
|
||||||
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
|
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
|
||||||
|
captureStage('finalizeResult', finalizeStartedAt)
|
||||||
|
|
||||||
|
const totalMs = Date.now() - startedAt
|
||||||
|
if (totalMs >= this.slowGetContactsLogThresholdMs) {
|
||||||
|
const stageSummary = stageDurations
|
||||||
|
.map((item) => `${item.stage}=${item.ms}ms`)
|
||||||
|
.join(', ')
|
||||||
|
console.warn(`[ChatService] getContacts(${isLiteMode ? 'lite' : 'full'}) 慢查询 total=${totalMs}ms, ${stageSummary}`)
|
||||||
|
}
|
||||||
return { success: true, contacts: result }
|
return { success: true, contacts: result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取通讯录失败:', e)
|
console.error('ChatService: 获取通讯录失败:', e)
|
||||||
@@ -1880,6 +1959,568 @@ class ChatService {
|
|||||||
return Number.isFinite(parsed) ? parsed : fallback
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasAnyContactExtendedFieldKey(row: Record<string, any>): boolean {
|
||||||
|
for (const key of Object.keys(row || {})) {
|
||||||
|
if (this.contactExtendedFieldCandidateSet.has(String(key || '').toLowerCase())) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateContactsWithExtendedFields(rows: Record<string, any>[]): Promise<Record<string, any>[]> {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) return rows
|
||||||
|
const hasAnyExtendedFieldKey = rows.some((row) => this.hasAnyContactExtendedFieldKey(row || {}))
|
||||||
|
if (hasAnyExtendedFieldKey) {
|
||||||
|
// wcdb_get_contacts_compact 可能只给“部分联系人”返回 extra_buffer。
|
||||||
|
// 只有在每一行都能拿到可解析的 extra_buffer 时才跳过补偿查询。
|
||||||
|
const allRowsHaveUsableExtraBuffer = rows.every((row) => this.toExtraBufferBytes(row || {}) !== null)
|
||||||
|
if (allRowsHaveUsableExtraBuffer) return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let selectableColumns = this.contactExtendedSelectableColumns
|
||||||
|
if (!selectableColumns) {
|
||||||
|
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact)')
|
||||||
|
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows)) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableColumns = new Map<string, string>()
|
||||||
|
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
|
||||||
|
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
|
||||||
|
const name = String(rawName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
availableColumns.set(name.toLowerCase(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedColumns: string[] = []
|
||||||
|
const seenColumns = new Set<string>()
|
||||||
|
for (const candidate of this.contactExtendedFieldCandidates) {
|
||||||
|
const actual = availableColumns.get(candidate.toLowerCase())
|
||||||
|
if (!actual) continue
|
||||||
|
const normalized = actual.toLowerCase()
|
||||||
|
if (seenColumns.has(normalized)) continue
|
||||||
|
seenColumns.add(normalized)
|
||||||
|
resolvedColumns.push(actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contactExtendedSelectableColumns = resolvedColumns
|
||||||
|
selectableColumns = resolvedColumns
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectableColumns.length === 0) return rows
|
||||||
|
|
||||||
|
const selectColumns = ['username', ...selectableColumns]
|
||||||
|
const sql = `SELECT ${selectColumns.map((column) => this.quoteSqlIdentifier(column)).join(', ')} FROM contact WHERE username IS NOT NULL AND username != ''`
|
||||||
|
const extendedResult = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (!extendedResult.success || !Array.isArray(extendedResult.rows) || extendedResult.rows.length === 0) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedByUsername = new Map<string, Record<string, any>>()
|
||||||
|
for (const extendedRow of extendedResult.rows as Record<string, any>[]) {
|
||||||
|
const username = String(extendedRow.username || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
extendedByUsername.set(username, extendedRow)
|
||||||
|
}
|
||||||
|
if (extendedByUsername.size === 0) return rows
|
||||||
|
|
||||||
|
return rows.map((row) => {
|
||||||
|
const username = String(row.username || row.user_name || row.userName || '').trim()
|
||||||
|
if (!username) return row
|
||||||
|
const extended = extendedByUsername.get(username)
|
||||||
|
if (!extended) return row
|
||||||
|
return {
|
||||||
|
...extended,
|
||||||
|
...row
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('联系人扩展字段补偿查询失败:', error)
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getContactLabelNameMap(): Promise<Map<number, string>> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (this.contactLabelNameMapCache && now - this.contactLabelNameMapCacheAt <= this.contactLabelNameMapCacheTtlMs) {
|
||||||
|
return new Map(this.contactLabelNameMapCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelMap = new Map<number, string>()
|
||||||
|
try {
|
||||||
|
const tableInfoResult = await wcdbService.execQuery('contact', null, 'PRAGMA table_info(contact_label)')
|
||||||
|
if (!tableInfoResult.success || !Array.isArray(tableInfoResult.rows) || tableInfoResult.rows.length === 0) {
|
||||||
|
this.contactLabelNameMapCache = labelMap
|
||||||
|
this.contactLabelNameMapCacheAt = now
|
||||||
|
return labelMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableColumns = new Map<string, string>()
|
||||||
|
for (const tableInfoRow of tableInfoResult.rows as Record<string, any>[]) {
|
||||||
|
const rawName = tableInfoRow.name ?? tableInfoRow.column_name ?? tableInfoRow.columnName
|
||||||
|
const name = String(rawName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
availableColumns.set(name.toLowerCase(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickColumn = (candidates: string[]): string | null => {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const actual = availableColumns.get(candidate.toLowerCase())
|
||||||
|
if (actual) return actual
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const idColumn = pickColumn(['label_id_', 'label_id', 'labelId', 'labelid', 'id'])
|
||||||
|
const nameColumn = pickColumn(['label_name_', 'label_name', 'labelName', 'labelname', 'name'])
|
||||||
|
if (!idColumn || !nameColumn) {
|
||||||
|
this.contactLabelNameMapCache = labelMap
|
||||||
|
this.contactLabelNameMapCacheAt = now
|
||||||
|
return labelMap
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `SELECT ${this.quoteSqlIdentifier(idColumn)} AS label_id, ${this.quoteSqlIdentifier(nameColumn)} AS label_name FROM contact_label`
|
||||||
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (result.success && Array.isArray(result.rows)) {
|
||||||
|
for (const row of result.rows as Record<string, any>[]) {
|
||||||
|
const id = Number(String(row.label_id ?? row.labelId ?? '').trim())
|
||||||
|
const name = String(row.label_name ?? row.labelName ?? '').trim()
|
||||||
|
if (Number.isFinite(id) && id > 0 && name) {
|
||||||
|
labelMap.set(Math.floor(id), name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('读取 contact_label 失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.contactLabelNameMapCache = labelMap
|
||||||
|
this.contactLabelNameMapCacheAt = now
|
||||||
|
return new Map(labelMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private toExtraBufferBytes(row: Record<string, any>): Buffer | null {
|
||||||
|
const raw = this.getRowField(row, ['extra_buffer', 'extraBuffer'])
|
||||||
|
if (raw === undefined || raw === null) return null
|
||||||
|
if (Buffer.isBuffer(raw)) return raw.length > 0 ? raw : null
|
||||||
|
if (raw instanceof Uint8Array) return raw.length > 0 ? Buffer.from(raw) : null
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const bytes = Buffer.from(raw)
|
||||||
|
return bytes.length > 0 ? bytes : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return null
|
||||||
|
const compact = text.replace(/\s+/g, '')
|
||||||
|
if (compact.length >= 2 && compact.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(compact)) {
|
||||||
|
try {
|
||||||
|
const bytes = Buffer.from(compact, 'hex')
|
||||||
|
return bytes.length > 0 ? bytes : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private readProtoVarint(buffer: Buffer, offset: number): { value: number; nextOffset: number } | null {
|
||||||
|
if (!buffer || offset < 0 || offset >= buffer.length) return null
|
||||||
|
let value = 0
|
||||||
|
let shift = 0
|
||||||
|
let index = offset
|
||||||
|
while (index < buffer.length) {
|
||||||
|
const byte = buffer[index]
|
||||||
|
index += 1
|
||||||
|
value += (byte & 0x7f) * Math.pow(2, shift)
|
||||||
|
if ((byte & 0x80) === 0) {
|
||||||
|
return { value, nextOffset: index }
|
||||||
|
}
|
||||||
|
shift += 7
|
||||||
|
if (shift > 56) return null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractExtraBufferTopLevelFieldStrings(row: Record<string, any>, targetField: number): string[] {
|
||||||
|
const bytes = this.toExtraBufferBytes(row)
|
||||||
|
if (!bytes || !Number.isFinite(targetField) || targetField <= 0) return []
|
||||||
|
const values: string[] = []
|
||||||
|
let offset = 0
|
||||||
|
while (offset < bytes.length) {
|
||||||
|
const tagResult = this.readProtoVarint(bytes, offset)
|
||||||
|
if (!tagResult) break
|
||||||
|
offset = tagResult.nextOffset
|
||||||
|
const fieldNumber = Math.floor(tagResult.value / 8)
|
||||||
|
const wireType = tagResult.value & 0x07
|
||||||
|
|
||||||
|
if (wireType === 0) {
|
||||||
|
const varint = this.readProtoVarint(bytes, offset)
|
||||||
|
if (!varint) break
|
||||||
|
offset = varint.nextOffset
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 1) {
|
||||||
|
if (offset + 8 > bytes.length) break
|
||||||
|
offset += 8
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 2) {
|
||||||
|
const lengthResult = this.readProtoVarint(bytes, offset)
|
||||||
|
if (!lengthResult) break
|
||||||
|
const payloadLength = Math.floor(lengthResult.value)
|
||||||
|
offset = lengthResult.nextOffset
|
||||||
|
if (payloadLength < 0 || offset + payloadLength > bytes.length) break
|
||||||
|
const payload = bytes.subarray(offset, offset + payloadLength)
|
||||||
|
offset += payloadLength
|
||||||
|
if (fieldNumber === targetField) {
|
||||||
|
const text = payload.toString('utf-8').replace(/\u0000/g, '').trim()
|
||||||
|
if (text) values.push(text)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wireType === 5) {
|
||||||
|
if (offset + 4 > bytes.length) break
|
||||||
|
offset += 4
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseContactLabelsFromExtraBuffer(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
|
||||||
|
const labelNames: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const texts = this.extractExtraBufferTopLevelFieldStrings(row, 30)
|
||||||
|
for (const text of texts) {
|
||||||
|
const matches = text.match(/\d+/g) || []
|
||||||
|
for (const match of matches) {
|
||||||
|
const id = Number(match)
|
||||||
|
if (!Number.isFinite(id) || id <= 0) continue
|
||||||
|
const labelName = labelNameMap?.get(Math.floor(id))
|
||||||
|
if (!labelName) continue
|
||||||
|
if (seen.has(labelName)) continue
|
||||||
|
seen.add(labelName)
|
||||||
|
labelNames.push(labelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labelNames
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseContactLabels(row: Record<string, any>, labelNameMap?: Map<number, string>): string[] {
|
||||||
|
const raw = this.getRowField(row, [
|
||||||
|
'label_list', 'labelList', 'labels', 'label_names', 'labelNames', 'tags', 'tag_list', 'tagList'
|
||||||
|
])
|
||||||
|
const normalizedFromValue = (value: unknown): string[] => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||||
|
}
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (!text) return []
|
||||||
|
return Array.from(new Set(
|
||||||
|
text
|
||||||
|
.replace(/[;;、|]+/g, ',')
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const direct = normalizedFromValue(raw)
|
||||||
|
if (direct.length > 0) return direct
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(row)) {
|
||||||
|
const normalizedKey = key.toLowerCase()
|
||||||
|
if (!normalizedKey.includes('label') && !normalizedKey.includes('tag')) continue
|
||||||
|
if (normalizedKey.includes('img') || normalizedKey.includes('head')) continue
|
||||||
|
const fallback = normalizedFromValue(value)
|
||||||
|
if (fallback.length > 0) return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraBufferLabels = this.parseContactLabelsFromExtraBuffer(row, labelNameMap)
|
||||||
|
if (extraBufferLabels.length > 0) return extraBufferLabels
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContactSignature(row: Record<string, any>): string {
|
||||||
|
const normalize = (raw: unknown): string => {
|
||||||
|
const text = String(raw || '').replace(/\u0000/g, '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.getRowField(row, [
|
||||||
|
'signature', 'sign', 'personal_signature', 'personalSignature', 'profile', 'introduction',
|
||||||
|
'detail_description', 'detailDescription', 'description', 'desc', 'contact_description', 'contactDescription'
|
||||||
|
])
|
||||||
|
const direct = normalize(value)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
for (const [key, rawValue] of Object.entries(row)) {
|
||||||
|
const normalizedKey = key.toLowerCase()
|
||||||
|
const isCandidate =
|
||||||
|
normalizedKey.includes('sign') ||
|
||||||
|
normalizedKey.includes('signature') ||
|
||||||
|
normalizedKey.includes('profile') ||
|
||||||
|
normalizedKey.includes('intro') ||
|
||||||
|
normalizedKey.includes('description') ||
|
||||||
|
normalizedKey.includes('detail') ||
|
||||||
|
normalizedKey.includes('desc')
|
||||||
|
if (!isCandidate) continue
|
||||||
|
if (
|
||||||
|
normalizedKey.includes('avatar') ||
|
||||||
|
normalizedKey.includes('img') ||
|
||||||
|
normalizedKey.includes('head') ||
|
||||||
|
normalizedKey.includes('label') ||
|
||||||
|
normalizedKey.includes('tag')
|
||||||
|
) continue
|
||||||
|
const text = normalize(rawValue)
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// contact.extra_buffer field 4: 个性签名兜底
|
||||||
|
const signatures = this.extractExtraBufferTopLevelFieldStrings(row, 4)
|
||||||
|
for (const signature of signatures) {
|
||||||
|
const text = normalize(signature)
|
||||||
|
if (!text) continue
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeContactRegionPart(raw: unknown): string {
|
||||||
|
const text = String(raw || '').replace(/\u0000/g, '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
const lower = text.toLowerCase()
|
||||||
|
if (lower === '-' || lower === '--' || lower === '—' || lower === 'null' || lower === 'undefined' || lower === 'none') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRegionLookupKey(raw: string): string {
|
||||||
|
return String(raw || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRegionLookupCandidates(raw: string): string[] {
|
||||||
|
const normalized = this.normalizeRegionLookupKey(raw)
|
||||||
|
if (!normalized) return []
|
||||||
|
|
||||||
|
const candidates = new Set<string>([normalized])
|
||||||
|
const withoutTrailingDigits = normalized.replace(/\d+$/g, '')
|
||||||
|
if (withoutTrailingDigits) candidates.add(withoutTrailingDigits)
|
||||||
|
|
||||||
|
return Array.from(candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeChineseProvinceName(raw: string): string {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/特别行政区$/g, '')
|
||||||
|
.replace(/维吾尔自治区$/g, '')
|
||||||
|
.replace(/壮族自治区$/g, '')
|
||||||
|
.replace(/回族自治区$/g, '')
|
||||||
|
.replace(/自治区$/g, '')
|
||||||
|
.replace(/省$/g, '')
|
||||||
|
.replace(/市$/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeChineseCityName(raw: string): string {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (!text) return ''
|
||||||
|
return text
|
||||||
|
.replace(/特别行政区$/g, '')
|
||||||
|
.replace(/自治州$/g, '')
|
||||||
|
.replace(/地区$/g, '')
|
||||||
|
.replace(/盟$/g, '')
|
||||||
|
.replace(/林区$/g, '')
|
||||||
|
.replace(/市$/g, '')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProvinceLookupKey(raw: string): string {
|
||||||
|
const candidates = this.buildRegionLookupCandidates(raw)
|
||||||
|
if (candidates.length === 0) return ''
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const byName = CONTACT_REGION_LOOKUP_DATA.provinceKeyByName[candidate]
|
||||||
|
if (byName) return byName
|
||||||
|
if (CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate]) return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseCountryName(raw: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const candidates = this.buildRegionLookupCandidates(text)
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const mapped = CONTACT_REGION_LOOKUP_DATA.countryNameByKey[candidate]
|
||||||
|
if (mapped) return mapped
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseProvinceName(raw: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const candidates = this.buildRegionLookupCandidates(text)
|
||||||
|
if (candidates.length === 0) return text
|
||||||
|
const provinceKey = this.resolveProvinceLookupKey(text)
|
||||||
|
const mappedFromCandidates = candidates
|
||||||
|
.map((candidate) => CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[candidate])
|
||||||
|
.find(Boolean)
|
||||||
|
const mapped = CONTACT_REGION_LOOKUP_DATA.provinceNameByKey[provinceKey] || mappedFromCandidates
|
||||||
|
if (mapped) return mapped
|
||||||
|
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) {
|
||||||
|
return this.normalizeChineseProvinceName(text) || text
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseCityName(raw: string, provinceRaw?: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
|
||||||
|
const candidates = this.buildRegionLookupCandidates(text)
|
||||||
|
if (candidates.length === 0) return text
|
||||||
|
|
||||||
|
const provinceKey = this.resolveProvinceLookupKey(String(provinceRaw || ''))
|
||||||
|
if (provinceKey) {
|
||||||
|
const byProvince = CONTACT_REGION_LOOKUP_DATA.cityNameByProvinceKey[provinceKey]
|
||||||
|
if (byProvince) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const mappedInProvince = byProvince[candidate]
|
||||||
|
if (mappedInProvince) return mappedInProvince
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const mapped = CONTACT_REGION_LOOKUP_DATA.cityNameByKey[candidate]
|
||||||
|
if (mapped) return mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) {
|
||||||
|
return this.normalizeChineseCityName(text) || text
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
private toChineseRegionText(raw: string): string {
|
||||||
|
const text = this.normalizeContactRegionPart(raw)
|
||||||
|
if (!text) return ''
|
||||||
|
const tokens = text
|
||||||
|
.split(/[\s,,、/|·]+/)
|
||||||
|
.map((item) => this.normalizeContactRegionPart(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (tokens.length === 0) return text
|
||||||
|
|
||||||
|
let provinceContext = ''
|
||||||
|
const mapped = tokens.map((token) => {
|
||||||
|
const country = this.toChineseCountryName(token)
|
||||||
|
if (country !== token) return country
|
||||||
|
|
||||||
|
const province = this.toChineseProvinceName(token)
|
||||||
|
if (province !== token) {
|
||||||
|
provinceContext = province
|
||||||
|
return province
|
||||||
|
}
|
||||||
|
|
||||||
|
const city = this.toChineseCityName(token, provinceContext)
|
||||||
|
if (city !== token) return city
|
||||||
|
|
||||||
|
return token
|
||||||
|
})
|
||||||
|
return mapped.join(' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldHideCountryInRegion(country: string, hasProvinceOrCity: boolean): boolean {
|
||||||
|
if (!country) return true
|
||||||
|
const normalized = country.toLowerCase()
|
||||||
|
if (normalized === 'cn' || normalized === 'chn' || normalized === 'china' || normalized === '中国') {
|
||||||
|
return hasProvinceOrCity
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private getContactRegion(row: Record<string, any>): string {
|
||||||
|
const pickByTokens = (tokens: string[]): string => {
|
||||||
|
for (const [key, value] of Object.entries(row || {})) {
|
||||||
|
const normalizedKey = String(key || '').toLowerCase()
|
||||||
|
if (!normalizedKey) continue
|
||||||
|
if (normalizedKey.includes('avatar') || normalizedKey.includes('img') || normalizedKey.includes('head')) continue
|
||||||
|
if (!tokens.some((token) => normalizedKey.includes(token))) continue
|
||||||
|
const text = this.normalizeContactRegionPart(value)
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const directCountry = this.normalizeContactRegionPart(this.getRowField(row, ['country', 'Country'])) || pickByTokens(['country'])
|
||||||
|
const directProvince = this.normalizeContactRegionPart(this.getRowField(row, ['province', 'Province'])) || pickByTokens(['province'])
|
||||||
|
const directCity = this.normalizeContactRegionPart(this.getRowField(row, ['city', 'City'])) || pickByTokens(['city'])
|
||||||
|
const directRegion =
|
||||||
|
this.normalizeContactRegionPart(this.getRowField(row, ['region', 'Region', 'location', 'area'])) ||
|
||||||
|
pickByTokens(['region', 'location', 'area', 'addr', 'address'])
|
||||||
|
|
||||||
|
if (directRegion) {
|
||||||
|
const normalizedRegion = this.toChineseRegionText(directRegion)
|
||||||
|
const parts = normalizedRegion
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((item) => this.normalizeContactRegionPart(item))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (parts.length > 1 && this.shouldHideCountryInRegion(parts[0], true)) {
|
||||||
|
return parts.slice(1).join(' ').trim()
|
||||||
|
}
|
||||||
|
return normalizedRegion
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackCountry = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 5)[0] || '')
|
||||||
|
const fallbackProvince = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 6)[0] || '')
|
||||||
|
const fallbackCity = this.normalizeContactRegionPart(this.extractExtraBufferTopLevelFieldStrings(row, 7)[0] || '')
|
||||||
|
|
||||||
|
const country = this.toChineseCountryName(directCountry || fallbackCountry)
|
||||||
|
const province = this.toChineseProvinceName(directProvince || fallbackProvince)
|
||||||
|
const city = this.toChineseCityName(directCity || fallbackCity, directProvince || fallbackProvince)
|
||||||
|
|
||||||
|
const hasProvinceOrCity = Boolean(province || city)
|
||||||
|
const parts: string[] = []
|
||||||
|
if (!this.shouldHideCountryInRegion(country, hasProvinceOrCity)) {
|
||||||
|
parts.push(country)
|
||||||
|
}
|
||||||
|
if (province) {
|
||||||
|
parts.push(province)
|
||||||
|
}
|
||||||
|
if (city && city !== province) {
|
||||||
|
parts.push(city)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
|
private normalizeUnsignedIntegerToken(raw: any): string | undefined {
|
||||||
if (raw === undefined || raw === null || raw === '') return undefined
|
if (raw === undefined || raw === null || raw === '') return undefined
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,31 @@ class CloudControlService {
|
|||||||
private timer: NodeJS.Timeout | null = null
|
private timer: NodeJS.Timeout | null = null
|
||||||
private pages: Set<string> = new Set()
|
private pages: Set<string> = new Set()
|
||||||
private platformVersionCache: string | null = null
|
private platformVersionCache: string | null = null
|
||||||
|
private pendingReports: UsageStats[] = []
|
||||||
|
private flushInProgress = false
|
||||||
|
private retryDelayMs = 5_000
|
||||||
|
private consecutiveFailures = 0
|
||||||
|
private circuitOpenedAt = 0
|
||||||
|
private nextDelayOverrideMs: number | null = null
|
||||||
|
private initialized = false
|
||||||
|
|
||||||
|
private static readonly BASE_FLUSH_MS = 300_000
|
||||||
|
private static readonly JITTER_MS = 30_000
|
||||||
|
private static readonly MAX_BUFFER_REPORTS = 200
|
||||||
|
private static readonly MAX_BATCH_REPORTS = 20
|
||||||
|
private static readonly MAX_RETRY_MS = 120_000
|
||||||
|
private static readonly CIRCUIT_FAIL_THRESHOLD = 5
|
||||||
|
private static readonly CIRCUIT_COOLDOWN_MS = 120_000
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
if (this.initialized) return
|
||||||
|
this.initialized = true
|
||||||
this.deviceId = this.getDeviceId()
|
this.deviceId = this.getDeviceId()
|
||||||
await wcdbService.cloudInit(300)
|
await wcdbService.cloudInit(300)
|
||||||
await this.reportOnline()
|
this.enqueueCurrentReport()
|
||||||
|
await this.flushQueue(true)
|
||||||
this.timer = setInterval(() => {
|
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||||
this.reportOnline()
|
this.nextDelayOverrideMs = null
|
||||||
}, 300000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDeviceId(): string {
|
private getDeviceId(): string {
|
||||||
@@ -33,8 +49,8 @@ class CloudControlService {
|
|||||||
return crypto.createHash('md5').update(machineId).digest('hex')
|
return crypto.createHash('md5').update(machineId).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reportOnline() {
|
private buildCurrentReport(): UsageStats {
|
||||||
const data: UsageStats = {
|
return {
|
||||||
appVersion: app.getVersion(),
|
appVersion: app.getVersion(),
|
||||||
platform: this.getPlatformVersion(),
|
platform: this.getPlatformVersion(),
|
||||||
deviceId: this.deviceId,
|
deviceId: this.deviceId,
|
||||||
@@ -42,11 +58,69 @@ class CloudControlService {
|
|||||||
online: true,
|
online: true,
|
||||||
pages: Array.from(this.pages)
|
pages: Array.from(this.pages)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await wcdbService.cloudReport(JSON.stringify(data))
|
private enqueueCurrentReport() {
|
||||||
|
const report = this.buildCurrentReport()
|
||||||
|
this.pendingReports.push(report)
|
||||||
|
if (this.pendingReports.length > CloudControlService.MAX_BUFFER_REPORTS) {
|
||||||
|
this.pendingReports.splice(0, this.pendingReports.length - CloudControlService.MAX_BUFFER_REPORTS)
|
||||||
|
}
|
||||||
this.pages.clear()
|
this.pages.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isCircuitOpen(nowMs: number): boolean {
|
||||||
|
if (this.circuitOpenedAt <= 0) return false
|
||||||
|
return nowMs-this.circuitOpenedAt < CloudControlService.CIRCUIT_COOLDOWN_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextFlush(delayMs?: number) {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
}
|
||||||
|
const jitter = Math.floor(Math.random() * CloudControlService.JITTER_MS)
|
||||||
|
const nextDelay = Math.max(1_000, Number(delayMs) > 0 ? Number(delayMs) : CloudControlService.BASE_FLUSH_MS + jitter)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.enqueueCurrentReport()
|
||||||
|
this.flushQueue(false).finally(() => {
|
||||||
|
this.scheduleNextFlush(this.nextDelayOverrideMs ?? undefined)
|
||||||
|
this.nextDelayOverrideMs = null
|
||||||
|
})
|
||||||
|
}, nextDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flushQueue(force: boolean) {
|
||||||
|
if (this.flushInProgress) return
|
||||||
|
if (this.pendingReports.length === 0) return
|
||||||
|
const now = Date.now()
|
||||||
|
if (!force && this.isCircuitOpen(now)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.flushInProgress = true
|
||||||
|
try {
|
||||||
|
while (this.pendingReports.length > 0) {
|
||||||
|
const batch = this.pendingReports.slice(0, CloudControlService.MAX_BATCH_REPORTS)
|
||||||
|
const result = await wcdbService.cloudReport(JSON.stringify(batch))
|
||||||
|
if (!result || result.success !== true) {
|
||||||
|
this.consecutiveFailures += 1
|
||||||
|
this.retryDelayMs = Math.min(CloudControlService.MAX_RETRY_MS, this.retryDelayMs * 2)
|
||||||
|
if (this.consecutiveFailures >= CloudControlService.CIRCUIT_FAIL_THRESHOLD) {
|
||||||
|
this.circuitOpenedAt = Date.now()
|
||||||
|
}
|
||||||
|
this.nextDelayOverrideMs = this.retryDelayMs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pendingReports.splice(0, batch.length)
|
||||||
|
this.consecutiveFailures = 0
|
||||||
|
this.retryDelayMs = 5_000
|
||||||
|
this.circuitOpenedAt = 0
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.flushInProgress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private getPlatformVersion(): string {
|
private getPlatformVersion(): string {
|
||||||
if (this.platformVersionCache) {
|
if (this.platformVersionCache) {
|
||||||
return this.platformVersionCache
|
return this.platformVersionCache
|
||||||
@@ -146,9 +220,16 @@ class CloudControlService {
|
|||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.timer) {
|
if (this.timer) {
|
||||||
clearInterval(this.timer)
|
clearTimeout(this.timer)
|
||||||
this.timer = null
|
this.timer = null
|
||||||
}
|
}
|
||||||
|
this.pendingReports = []
|
||||||
|
this.flushInProgress = false
|
||||||
|
this.retryDelayMs = 5_000
|
||||||
|
this.consecutiveFailures = 0
|
||||||
|
this.circuitOpenedAt = 0
|
||||||
|
this.nextDelayOverrideMs = null
|
||||||
|
this.initialized = false
|
||||||
wcdbService.cloudStop()
|
wcdbService.cloudStop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,4 +239,3 @@ class CloudControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const cloudControlService = new CloudControlService()
|
export const cloudControlService = new CloudControlService()
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ interface ConfigSchema {
|
|||||||
|
|
||||||
// 更新相关
|
// 更新相关
|
||||||
ignoredUpdateVersion: string
|
ignoredUpdateVersion: string
|
||||||
|
updateChannel: 'auto' | 'stable' | 'preview' | 'dev'
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
notificationEnabled: boolean
|
notificationEnabled: boolean
|
||||||
@@ -52,13 +53,17 @@ interface ConfigSchema {
|
|||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
messagePushEnabled: boolean
|
messagePushEnabled: boolean
|
||||||
|
httpApiEnabled: boolean
|
||||||
|
httpApiPort: number
|
||||||
|
httpApiHost: string
|
||||||
|
httpApiToken: string
|
||||||
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
windowCloseBehavior: 'ask' | 'tray' | 'quit'
|
||||||
quoteLayout: 'quote-top' | 'quote-bottom'
|
quoteLayout: 'quote-top' | 'quote-bottom'
|
||||||
wordCloudExcludeWords: string[]
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需要 safeStorage 加密的字段(普通模式)
|
// 需要 safeStorage 加密的字段(普通模式)
|
||||||
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
|
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword', 'httpApiToken'])
|
||||||
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
|
||||||
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
|
||||||
|
|
||||||
@@ -115,10 +120,15 @@ export class ConfigService {
|
|||||||
authUseHello: false,
|
authUseHello: false,
|
||||||
authHelloSecret: '',
|
authHelloSecret: '',
|
||||||
ignoredUpdateVersion: '',
|
ignoredUpdateVersion: '',
|
||||||
|
updateChannel: 'auto',
|
||||||
notificationEnabled: true,
|
notificationEnabled: true,
|
||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: [],
|
notificationFilterList: [],
|
||||||
|
httpApiToken: '',
|
||||||
|
httpApiEnabled: false,
|
||||||
|
httpApiPort: 5031,
|
||||||
|
httpApiHost: '127.0.0.1',
|
||||||
messagePushEnabled: false,
|
messagePushEnabled: false,
|
||||||
windowCloseBehavior: 'ask',
|
windowCloseBehavior: 'ask',
|
||||||
quoteLayout: 'quote-top',
|
quoteLayout: 'quote-top',
|
||||||
@@ -662,11 +672,9 @@ export class ConfigService {
|
|||||||
|
|
||||||
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
|
||||||
const rawDecryptKey: any = this.store.get('decryptKey')
|
const rawDecryptKey: any = this.store.get('decryptKey')
|
||||||
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
|
return typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX);
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === 工具方法 ===
|
// === 工具方法 ===
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class ContactExportService {
|
|||||||
displayName: c.displayName,
|
displayName: c.displayName,
|
||||||
remark: c.remark,
|
remark: c.remark,
|
||||||
nickname: c.nickname,
|
nickname: c.nickname,
|
||||||
|
alias: c.alias,
|
||||||
|
labels: Array.isArray(c.labels) ? c.labels : [],
|
||||||
|
detailDescription: c.detailDescription,
|
||||||
type: c.type
|
type: c.type
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -103,12 +106,15 @@ class ContactExportService {
|
|||||||
* 导出为CSV格式
|
* 导出为CSV格式
|
||||||
*/
|
*/
|
||||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||||
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
const headers = ['用户名', '显示名称', '备注', '昵称', '微信号', '标签', '详细描述', '类型']
|
||||||
const rows = contacts.map(c => [
|
const rows = contacts.map(c => [
|
||||||
c.username || '',
|
c.username || '',
|
||||||
c.displayName || '',
|
c.displayName || '',
|
||||||
c.remark || '',
|
c.remark || '',
|
||||||
c.nickname || '',
|
c.nickname || '',
|
||||||
|
c.alias || '',
|
||||||
|
Array.isArray(c.labels) ? c.labels.join(' | ') : '',
|
||||||
|
c.detailDescription || '',
|
||||||
this.getTypeLabel(c.type)
|
this.getTypeLabel(c.type)
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -137,9 +143,13 @@ class ContactExportService {
|
|||||||
lines.push(`NICKNAME:${c.nickname}`)
|
lines.push(`NICKNAME:${c.nickname}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 备注
|
const noteParts = [
|
||||||
if (c.remark) {
|
c.remark ? String(c.remark) : '',
|
||||||
lines.push(`NOTE:${c.remark}`)
|
Array.isArray(c.labels) && c.labels.length > 0 ? `标签: ${c.labels.join(', ')}` : '',
|
||||||
|
c.detailDescription ? `详细描述: ${c.detailDescription}` : ''
|
||||||
|
].filter(Boolean)
|
||||||
|
if (noteParts.length > 0) {
|
||||||
|
lines.push(`NOTE:${noteParts.join('\\n')}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 微信ID
|
// 微信ID
|
||||||
|
|||||||
9440
electron/services/contactRegionLookupData.ts
Normal file
9440
electron/services/contactRegionLookupData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -93,27 +93,39 @@ export class DbPathService {
|
|||||||
const possiblePaths: string[] = []
|
const possiblePaths: string[] = []
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
|
|
||||||
// macOS 微信路径(固定)
|
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
|
// macOS 微信 4.0.5+ 新路径(优先检测)
|
||||||
|
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||||
|
if (existsSync(appSupportBase)) {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(appSupportBase)
|
||||||
|
for (const entry of entries) {
|
||||||
|
// 匹配形如 2.0b4.0.9 的版本目录
|
||||||
|
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||||
|
possiblePaths.push(join(appSupportBase, entry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// macOS 旧路径兜底
|
||||||
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files'))
|
||||||
} else {
|
} else {
|
||||||
// Windows 微信4.x 数据目录
|
// Windows 微信4.x 数据目录
|
||||||
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
possiblePaths.push(join(home, 'Documents', 'xwechat_files'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
for (const path of possiblePaths) {
|
for (const path of possiblePaths) {
|
||||||
if (existsSync(path)) {
|
if (!existsSync(path)) continue
|
||||||
const rootName = path.split(/[/\\]/).pop()?.toLowerCase()
|
|
||||||
if (rootName !== 'xwechat_files' && rootName !== 'wechat files') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有有效的账号目录
|
// 检查是否有有效的账号目录,或本身就是账号目录
|
||||||
const accounts = this.findAccountDirs(path)
|
const accounts = this.findAccountDirs(path)
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
return { success: true, path }
|
return { success: true, path }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果该目录本身就是账号目录(直接包含 db_storage 等)
|
||||||
|
if (this.isAccountDir(path)) {
|
||||||
|
return { success: true, path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +307,20 @@ export class DbPathService {
|
|||||||
getDefaultPath(): string {
|
getDefaultPath(): string {
|
||||||
const home = homedir()
|
const home = homedir()
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
|
// 优先返回 4.0.5+ 新路径
|
||||||
|
const appSupportBase = join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Library', 'Application Support', 'com.tencent.xinWeChat')
|
||||||
|
if (existsSync(appSupportBase)) {
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(appSupportBase)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (/^\d+\.\d+b\d+\.\d+/.test(entry) || /^\d+\.\d+\.\d+/.test(entry)) {
|
||||||
|
const candidate = join(appSupportBase, entry)
|
||||||
|
if (existsSync(candidate)) return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
// 旧版本路径兜底
|
||||||
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
return join(home, 'Library', 'Containers', 'com.tencent.xinWeChat', 'Data', 'Documents', 'xwechat_files')
|
||||||
}
|
}
|
||||||
return join(home, 'Documents', 'xwechat_files')
|
return join(home, 'Documents', 'xwechat_files')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { chatService } from './chatService'
|
import { chatService } from './chatService'
|
||||||
import type { Message } from './chatService'
|
import type { Message } from './chatService'
|
||||||
|
import type { ChatStatistics } from './analyticsService'
|
||||||
|
|
||||||
export interface GroupChatInfo {
|
export interface GroupChatInfo {
|
||||||
username: string
|
username: string
|
||||||
@@ -49,6 +50,13 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupMemberAnalytics {
|
||||||
|
statistics: ChatStatistics
|
||||||
|
timeDistribution: Record<number, number>
|
||||||
|
commonPhrases?: Array<{ phrase: string; count: number }>
|
||||||
|
commonEmojis?: Array<{ emoji: string; count: number }>
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupMemberMessagesPage {
|
export interface GroupMemberMessagesPage {
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
@@ -797,7 +805,12 @@ class GroupAnalyticsService {
|
|||||||
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
return normalized > 10000000000 ? Math.floor(normalized / 1000) : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractRowSenderUsername(row: Record<string, any>): string {
|
private extractRowSenderUsername(row: Record<string, any>, myWxid?: string): string {
|
||||||
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||||
|
if (isSendRaw != null && parseInt(isSendRaw, 10) === 1 && myWxid) {
|
||||||
|
return myWxid
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
row.sender_username,
|
row.sender_username,
|
||||||
row.senderUsername,
|
row.senderUsername,
|
||||||
@@ -820,13 +833,33 @@ class GroupAnalyticsService {
|
|||||||
if (normalizedValue) return normalizedValue
|
if (normalizedValue) return normalizedValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: fast extract from raw content to avoid full parse
|
||||||
|
const rawContent = String(row.StrContent || row.message_content || row.content || row.msg_content || '').trim()
|
||||||
|
if (rawContent) {
|
||||||
|
const match = /^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i.exec(rawContent)
|
||||||
|
if (match && match[1]) {
|
||||||
|
return match[1].trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
private parseSingleMessageRow(row: Record<string, any>): Message | null {
|
||||||
try {
|
try {
|
||||||
const mapped = chatService.mapRowsToMessagesForApi([row])
|
const mapped = chatService.mapRowsToMessagesForApi([row])
|
||||||
return Array.isArray(mapped) && mapped.length > 0 ? mapped[0] : null
|
if (Array.isArray(mapped) && mapped.length > 0) {
|
||||||
|
const msg = mapped[0]
|
||||||
|
if (!msg.localType) {
|
||||||
|
msg.localType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||||
|
}
|
||||||
|
if (!msg.createTime) {
|
||||||
|
msg.createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -881,7 +914,7 @@ class GroupAnalyticsService {
|
|||||||
if (rows.length === 0) break
|
if (rows.length === 0) break
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const senderFromRow = this.extractRowSenderUsername(row)
|
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -987,7 +1020,7 @@ class GroupAnalyticsService {
|
|||||||
const row = rows[index]
|
const row = rows[index]
|
||||||
consumedRows += 1
|
consumedRows += 1
|
||||||
|
|
||||||
const senderFromRow = this.extractRowSenderUsername(row)
|
const senderFromRow = this.extractRowSenderUsername(row, String(this.configService.get('myWxid') || '').trim())
|
||||||
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
if (senderFromRow && !matchesTargetSender(senderFromRow)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1467,6 +1500,154 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getGroupMemberAnalytics(
|
||||||
|
chatroomId: string,
|
||||||
|
memberUsername: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number
|
||||||
|
): Promise<{ success: boolean; data?: GroupMemberAnalytics; error?: string }> {
|
||||||
|
try {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
const normalizedMemberUsername = String(memberUsername || '').trim()
|
||||||
|
|
||||||
|
const batchSize = 10000
|
||||||
|
const senderMatchCache = new Map<string, boolean>()
|
||||||
|
const matchesTargetSender = (sender: string | null | undefined): boolean => {
|
||||||
|
const key = String(sender || '').trim().toLowerCase()
|
||||||
|
if (!key) return false
|
||||||
|
const cached = senderMatchCache.get(key)
|
||||||
|
if (typeof cached === 'boolean') return cached
|
||||||
|
const matched = this.isSameAccountIdentity(normalizedMemberUsername, sender)
|
||||||
|
senderMatchCache.set(key, matched)
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorResult = await this.openMemberMessageCursor(normalizedChatroomId, batchSize, true, startTime || 0, endTime || 0)
|
||||||
|
if (!cursorResult.success || !cursorResult.cursor) {
|
||||||
|
return { success: false, error: cursorResult.error || '创建游标失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = cursorResult.cursor
|
||||||
|
const stats: ChatStatistics = {
|
||||||
|
totalMessages: 0,
|
||||||
|
textMessages: 0,
|
||||||
|
imageMessages: 0,
|
||||||
|
voiceMessages: 0,
|
||||||
|
videoMessages: 0,
|
||||||
|
emojiMessages: 0,
|
||||||
|
otherMessages: 0,
|
||||||
|
sentMessages: 0, // In group, we only fetch messages of this member, so sentMessages = totalMessages
|
||||||
|
receivedMessages: 0, // No meaning here
|
||||||
|
firstMessageTime: null,
|
||||||
|
lastMessageTime: null,
|
||||||
|
activeDays: 0,
|
||||||
|
messageTypeCounts: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyDistribution: Record<number, number> = {}
|
||||||
|
for (let i = 0; i < 24; i++) hourlyDistribution[i] = 0
|
||||||
|
const dailySet = new Set<string>()
|
||||||
|
const textTypes = [1, 244813135921]
|
||||||
|
|
||||||
|
const phraseCounts = new Map<string, number>()
|
||||||
|
const emojiCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
const myWxid = String(this.configService.get('myWxid') || '').trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const batch = await wcdbService.fetchMessageBatch(cursor)
|
||||||
|
if (!batch.success) {
|
||||||
|
return { success: false, error: batch.error || '获取分析数据失败' }
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(batch.rows) ? batch.rows as Record<string, any>[] : []
|
||||||
|
if (rows.length === 0) break
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
let senderFromRow = this.extractRowSenderUsername(row, myWxid)
|
||||||
|
|
||||||
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? row.WCDB_CT_is_send
|
||||||
|
const isSend = isSendRaw != null ? parseInt(isSendRaw, 10) === 1 : false
|
||||||
|
|
||||||
|
if (isSend) {
|
||||||
|
senderFromRow = myWxid
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!senderFromRow || !matchesTargetSender(senderFromRow)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = parseInt(row.Type || row.type || row.local_type || row.msg_type || '0', 10)
|
||||||
|
const createTime = parseInt(row.CreateTime || row.create_time || row.createTime || row.msg_time || '0', 10)
|
||||||
|
|
||||||
|
let content = String(row.StrContent || row.message_content || row.content || row.msg_content || '')
|
||||||
|
if (content) {
|
||||||
|
content = content.replace(/^\s*([a-zA-Z0-9_@-]{4,}):(?!\/\/)\s*(?:\r?\n|<br\s*\/?>)/i, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.totalMessages++
|
||||||
|
if (textTypes.includes(msgType)) {
|
||||||
|
stats.textMessages++
|
||||||
|
if (content) {
|
||||||
|
const text = content.trim()
|
||||||
|
if (text && text.length <= 20) {
|
||||||
|
phraseCounts.set(text, (phraseCounts.get(text) || 0) + 1)
|
||||||
|
}
|
||||||
|
const emojiMatches = text.match(/\[.*?\]/g)
|
||||||
|
if (emojiMatches) {
|
||||||
|
for (const em of emojiMatches) {
|
||||||
|
emojiCounts.set(em, (emojiCounts.get(em) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (msgType === 3) stats.imageMessages++
|
||||||
|
else if (msgType === 34) stats.voiceMessages++
|
||||||
|
else if (msgType === 43) stats.videoMessages++
|
||||||
|
else if (msgType === 47) stats.emojiMessages++
|
||||||
|
else stats.otherMessages++
|
||||||
|
|
||||||
|
stats.sentMessages++
|
||||||
|
|
||||||
|
stats.messageTypeCounts[msgType] = (stats.messageTypeCounts[msgType] || 0) + 1
|
||||||
|
|
||||||
|
if (createTime > 0) {
|
||||||
|
if (stats.firstMessageTime === null || createTime < stats.firstMessageTime) stats.firstMessageTime = createTime
|
||||||
|
if (stats.lastMessageTime === null || createTime > stats.lastMessageTime) stats.lastMessageTime = createTime
|
||||||
|
|
||||||
|
const d = new Date(createTime * 1000)
|
||||||
|
const hour = d.getHours()
|
||||||
|
hourlyDistribution[hour]++
|
||||||
|
dailySet.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!batch.hasMore) break
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await wcdbService.closeMessageCursor(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.activeDays = dailySet.size
|
||||||
|
|
||||||
|
const commonPhrases = Array.from(phraseCounts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([phrase, count]) => ({ phrase, count }))
|
||||||
|
|
||||||
|
const commonEmojis = Array.from(emojiCounts.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([emoji, count]) => ({ emoji, count }))
|
||||||
|
|
||||||
|
return { success: true, data: { statistics: stats, timeDistribution: hourlyDistribution, commonPhrases, commonEmojis } }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async exportGroupMemberMessages(
|
async exportGroupMemberMessages(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class HttpService {
|
|||||||
private server: http.Server | null = null
|
private server: http.Server | null = null
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private port: number = 5031
|
private port: number = 5031
|
||||||
|
private host: string = '127.0.0.1'
|
||||||
private running: boolean = false
|
private running: boolean = false
|
||||||
private connections: Set<import('net').Socket> = new Set()
|
private connections: Set<import('net').Socket> = new Set()
|
||||||
private messagePushClients: Set<http.ServerResponse> = new Set()
|
private messagePushClients: Set<http.ServerResponse> = new Set()
|
||||||
@@ -114,12 +115,13 @@ class HttpService {
|
|||||||
/**
|
/**
|
||||||
* 启动 HTTP 服务
|
* 启动 HTTP 服务
|
||||||
*/
|
*/
|
||||||
async start(port: number = 5031): Promise<{ success: boolean; port?: number; error?: string }> {
|
async start(port: number = 5031, host: string = '127.0.0.1'): Promise<{ success: boolean; port?: number; error?: string }> {
|
||||||
if (this.running && this.server) {
|
if (this.running && this.server) {
|
||||||
return { success: true, port: this.port }
|
return { success: true, port: this.port }
|
||||||
}
|
}
|
||||||
|
|
||||||
this.port = port
|
this.port = port
|
||||||
|
this.host = host
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
this.server = http.createServer((req, res) => this.handleRequest(req, res))
|
||||||
@@ -153,10 +155,10 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.server.listen(this.port, '127.0.0.1', () => {
|
this.server.listen(this.port, this.host, () => {
|
||||||
this.running = true
|
this.running = true
|
||||||
this.startMessagePushHeartbeat()
|
this.startMessagePushHeartbeat()
|
||||||
console.log(`[HttpService] HTTP API server started on http://127.0.0.1:${this.port}`)
|
console.log(`[HttpService] HTTP API server started on http://${this.host}:${this.port}`)
|
||||||
resolve({ success: true, port: this.port })
|
resolve({ success: true, port: this.port })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -225,7 +227,7 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMessagePushStreamUrl(): string {
|
getMessagePushStreamUrl(): string {
|
||||||
return `http://127.0.0.1:${this.port}/api/v1/push/messages`
|
return `http://${this.host}:${this.port}/api/v1/push/messages`
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastMessagePush(payload: Record<string, unknown>): void {
|
broadcastMessagePush(payload: Record<string, unknown>): void {
|
||||||
@@ -246,14 +248,68 @@ class HttpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async autoStart(): Promise<void> {
|
||||||
|
const enabled = this.configService.get('httpApiEnabled')
|
||||||
|
if (enabled) {
|
||||||
|
const port = Number(this.configService.get('httpApiPort')) || 5031
|
||||||
|
const host = String(this.configService.get('httpApiHost') || '127.0.0.1').trim() || '127.0.0.1'
|
||||||
|
try {
|
||||||
|
await this.start(port, host)
|
||||||
|
console.log(`[HttpService] Auto-started on port ${port}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HttpService] Auto-start failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理 HTTP 请求
|
* 解析 POST 请求的 JSON Body
|
||||||
|
*/
|
||||||
|
private async parseBody(req: http.IncomingMessage): Promise<Record<string, any>> {
|
||||||
|
if (req.method !== 'POST') return {}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = ''
|
||||||
|
req.on('data', chunk => { body += chunk.toString() })
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
resolve({})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('error', () => resolve({}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 鉴权拦截器
|
||||||
|
*/
|
||||||
|
private verifyToken(req: http.IncomingMessage, url: URL, body: Record<string, any>): boolean {
|
||||||
|
const expectedToken = String(this.configService.get('httpApiToken') || '').trim()
|
||||||
|
if (!expectedToken) return true
|
||||||
|
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (authHeader && authHeader.toLowerCase().startsWith('bearer ')) {
|
||||||
|
const token = authHeader.substring(7).trim()
|
||||||
|
if (token === expectedToken) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryToken = url.searchParams.get('access_token')
|
||||||
|
if (queryToken && queryToken.trim() === expectedToken) return true
|
||||||
|
|
||||||
|
const bodyToken = body['access_token']
|
||||||
|
return !!(bodyToken && String(bodyToken).trim() === expectedToken);
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HTTP 请求 (重构后)
|
||||||
*/
|
*/
|
||||||
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||||
// 设置 CORS 头
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS')
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization')
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
res.writeHead(204)
|
res.writeHead(204)
|
||||||
@@ -261,11 +317,25 @@ class HttpService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`)
|
const url = new URL(req.url || '/', `http://${this.host}:${this.port}`)
|
||||||
const pathname = url.pathname
|
const pathname = url.pathname
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 路由处理
|
const bodyParams = await this.parseBody(req)
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(bodyParams)) {
|
||||||
|
if (!url.searchParams.has(key)) {
|
||||||
|
url.searchParams.set(key, String(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname !== '/health' && pathname !== '/api/v1/health') {
|
||||||
|
if (!this.verifyToken(req, url, bodyParams)) {
|
||||||
|
this.sendError(res, 401, 'Unauthorized: Invalid or missing access_token')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/health' || pathname === '/api/v1/health') {
|
if (pathname === '/health' || pathname === '/api/v1/health') {
|
||||||
this.sendJson(res, { status: 'ok' })
|
this.sendJson(res, { status: 'ok' })
|
||||||
} else if (pathname === '/api/v1/push/messages') {
|
} else if (pathname === '/api/v1/push/messages') {
|
||||||
@@ -288,7 +358,6 @@ class HttpService {
|
|||||||
this.sendError(res, 500, String(error))
|
this.sendError(res, 500, String(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private startMessagePushHeartbeat(): void {
|
private startMessagePushHeartbeat(): void {
|
||||||
if (this.messagePushHeartbeatTimer) return
|
if (this.messagePushHeartbeatTimer) return
|
||||||
this.messagePushHeartbeatTimer = setInterval(() => {
|
this.messagePushHeartbeatTimer = setInterval(() => {
|
||||||
@@ -895,7 +964,7 @@ class HttpService {
|
|||||||
parsedContent: msg.parsedContent,
|
parsedContent: msg.parsedContent,
|
||||||
mediaType: media?.kind,
|
mediaType: media?.kind,
|
||||||
mediaFileName: media?.fileName,
|
mediaFileName: media?.fileName,
|
||||||
mediaUrl: media ? `http://127.0.0.1:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
|
||||||
mediaLocalPath: media?.fullPath
|
mediaLocalPath: media?.fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1234,7 @@ class HttpService {
|
|||||||
type: this.mapMessageType(msg.localType, msg),
|
type: this.mapMessageType(msg.localType, msg),
|
||||||
content: this.getMessageContent(msg),
|
content: this.getMessageContent(msg),
|
||||||
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
|
||||||
mediaPath: mediaMap.get(msg.localId) ? `http://127.0.0.1:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -562,14 +562,14 @@ export class ImageDecryptService {
|
|||||||
if (allowThumbnail || !isThumb) {
|
if (allowThumbnail || !isThumb) {
|
||||||
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
this.logInfo('[ImageDecrypt] hardlink hit (datName)', { imageMd5: imageDatName, path: preferredPath })
|
||||||
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
this.cacheDatPath(accountDir, imageDatName, preferredPath)
|
||||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
this.cacheDatPath(accountDir, imageMd5, preferredPath)
|
||||||
return preferredPath
|
return preferredPath
|
||||||
}
|
}
|
||||||
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
// 找到缩略图但要求高清图,尝试同目录查找高清图变体
|
||||||
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
const hdPath = this.findHdVariantInSameDir(preferredPath)
|
||||||
if (hdPath) {
|
if (hdPath) {
|
||||||
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
this.cacheDatPath(accountDir, imageDatName, hdPath)
|
||||||
if (imageMd5) this.cacheDatPath(accountDir, imageMd5, hdPath)
|
this.cacheDatPath(accountDir, imageMd5, hdPath)
|
||||||
return hdPath
|
return hdPath
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ export class KeyServiceMac {
|
|||||||
`set timeoutSec to ${timeoutSec}`,
|
`set timeoutSec to ${timeoutSec}`,
|
||||||
'try',
|
'try',
|
||||||
'with timeout of timeoutSec seconds',
|
'with timeout of timeoutSec seconds',
|
||||||
'set outText to do shell script cmd with administrator privileges',
|
'set outText to do shell script (cmd & " 2>&1") with administrator privileges',
|
||||||
'end timeout',
|
'end timeout',
|
||||||
'return "WF_OK::" & outText',
|
'return "WF_OK::" & outText',
|
||||||
'on error errMsg number errNum partial result pr',
|
'on error errMsg number errNum partial result pr',
|
||||||
@@ -935,10 +935,17 @@ export class KeyServiceMac {
|
|||||||
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
private resolveXwechatRootFromPath(accountPath?: string): string | null {
|
||||||
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
const normalized = String(accountPath || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
||||||
if (!normalized) return null
|
if (!normalized) return null
|
||||||
|
|
||||||
|
// 旧路径:xwechat_files
|
||||||
const marker = '/xwechat_files'
|
const marker = '/xwechat_files'
|
||||||
const markerIdx = normalized.indexOf(marker)
|
const markerIdx = normalized.indexOf(marker)
|
||||||
if (markerIdx < 0) return null
|
if (markerIdx >= 0) return normalized.slice(0, markerIdx + marker.length)
|
||||||
return normalized.slice(0, markerIdx + marker.length)
|
|
||||||
|
// 新路径(微信 4.0.5+):Application Support/com.tencent.xinWeChat/2.0b4.0.9
|
||||||
|
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))(\/|$)/)
|
||||||
|
if (newMarkerMatch) return newMarkerMatch[1]
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
private pushAccountIdCandidates(candidates: string[], value?: string): void {
|
||||||
@@ -1096,6 +1103,16 @@ export class KeyServiceMac {
|
|||||||
candidates.add(`${base}/app_data/net/kvcomm`)
|
candidates.add(`${base}/app_data/net/kvcomm`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 微信 4.0.5+ 新路径推导:版本目录同级的 net/kvcomm
|
||||||
|
const newMarkerMatch = normalized.match(/^(.*\/com\.tencent\.xinWeChat\/(?:\d+\.\d+b\d+\.\d+|\d+\.\d+\.\d+))/)
|
||||||
|
if (newMarkerMatch) {
|
||||||
|
const versionBase = newMarkerMatch[1]
|
||||||
|
candidates.add(`${versionBase}/net/kvcomm`)
|
||||||
|
// 上级目录也尝试
|
||||||
|
const parentBase = versionBase.replace(/\/[^\/]+$/, '')
|
||||||
|
candidates.add(`${parentBase}/net/kvcomm`)
|
||||||
|
}
|
||||||
|
|
||||||
let cursor = accountPath
|
let cursor = accountPath
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
candidates.add(join(cursor, 'net', 'kvcomm'))
|
candidates.add(join(cursor, 'net', 'kvcomm'))
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ export class VoiceTranscribeService {
|
|||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
}
|
}
|
||||||
|
} else if (process.platform === 'win32') {
|
||||||
|
// Windows: 把 sherpa-onnx DLL 所在目录加到 PATH,否则 native module 找不到依赖
|
||||||
|
const existing = env['PATH'] || ''
|
||||||
|
const merged = [...candidates, ...existing.split(';').filter(Boolean)]
|
||||||
|
env['PATH'] = Array.from(new Set(merged)).join(';')
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
console.warn(`[VoiceTranscribe] 未找到 ${platformPkg} 目录,可能导致语音引擎加载失败`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env
|
return env
|
||||||
|
|||||||
@@ -304,7 +304,17 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private formatInitProtectionError(code: number): string {
|
private formatInitProtectionError(code: number): string {
|
||||||
return `错误码: ${code}`
|
const messages: Record<number, string> = {
|
||||||
|
'-3001': '未找到数据库目录 (db_storage),请确认已选择正确的微信数据目录(应包含以 wxid_ 开头的子文件夹)',
|
||||||
|
'-3002': '未找到 session.db 文件,请确认微信已登录并且数据目录完整',
|
||||||
|
'-3003': '数据库句柄无效,请重试',
|
||||||
|
'-3004': '恢复数据库连接失败,请重试',
|
||||||
|
'-2301': '动态库加载失败,请检查安装是否完整',
|
||||||
|
'-2302': 'WCDB 初始化异常,请重试',
|
||||||
|
'-2303': 'WCDB 未能成功初始化',
|
||||||
|
}
|
||||||
|
const msg = messages[String(code) as keyof typeof messages]
|
||||||
|
return msg ? `${msg} (错误码: ${code})` : `操作失败,错误码: ${code}`
|
||||||
}
|
}
|
||||||
|
|
||||||
private isLogEnabled(): boolean {
|
private isLogEnabled(): boolean {
|
||||||
@@ -480,6 +490,49 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
// 兜底:向上查找 db_storage(最多 2 级),处理用户选择了子目录的情况
|
||||||
|
try {
|
||||||
|
let parent = normalized
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const up = join(parent, '..')
|
||||||
|
if (up === parent) break
|
||||||
|
parent = up
|
||||||
|
const candidateUp = join(parent, 'db_storage')
|
||||||
|
if (existsSync(candidateUp)) return candidateUp
|
||||||
|
if (wxid) {
|
||||||
|
const viaWxidUp = join(parent, wxid, 'db_storage')
|
||||||
|
if (existsSync(viaWxidUp)) return viaWxidUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
// 兜底:递归搜索 basePath 下的 db_storage 目录(最多 3 层深)
|
||||||
|
try {
|
||||||
|
const found = this.findDbStorageRecursive(normalized, 3)
|
||||||
|
if (found) return found
|
||||||
|
} catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private findDbStorageRecursive(dir: string, maxDepth: number): string | null {
|
||||||
|
if (maxDepth <= 0) return null
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(dir)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.toLowerCase() === 'db_storage') {
|
||||||
|
const candidate = join(dir, entry)
|
||||||
|
try { if (statSync(candidate).isDirectory()) return candidate } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(dir, entry)
|
||||||
|
try {
|
||||||
|
if (statSync(entryPath).isDirectory()) {
|
||||||
|
const found = this.findDbStorageRecursive(entryPath, maxDepth - 1)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2543,13 +2596,34 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制重新打开账号连接(绕过路径缓存),用于微信重装后消息数据库刷新失败时的自动恢复。
|
||||||
|
* 返回重新打开是否成功。
|
||||||
|
*/
|
||||||
|
private async forceReopen(): Promise<boolean> {
|
||||||
|
if (!this.currentPath || !this.currentKey || !this.currentWxid) return false
|
||||||
|
const path = this.currentPath
|
||||||
|
const key = this.currentKey
|
||||||
|
const wxid = this.currentWxid
|
||||||
|
this.writeLog('forceReopen: clearing cached handle and reopening...', true)
|
||||||
|
// 清空缓存状态,让 open() 真正重新打开
|
||||||
|
try { this.wcdbShutdown() } catch { }
|
||||||
|
this.handle = null
|
||||||
|
this.currentPath = null
|
||||||
|
this.currentKey = null
|
||||||
|
this.currentWxid = null
|
||||||
|
this.currentDbStoragePath = null
|
||||||
|
this.initialized = false
|
||||||
|
return this.open(path, key, wxid)
|
||||||
|
}
|
||||||
|
|
||||||
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
async openMessageCursor(sessionId: string, batchSize: number, ascending: boolean, beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; cursor?: number; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const outCursor = [0]
|
const outCursor = [0]
|
||||||
const result = this.wcdbOpenMessageCursor(
|
let result = this.wcdbOpenMessageCursor(
|
||||||
this.handle,
|
this.handle,
|
||||||
sessionId,
|
sessionId,
|
||||||
batchSize,
|
batchSize,
|
||||||
@@ -2558,13 +2632,37 @@ export class WcdbCore {
|
|||||||
endTimestamp,
|
endTimestamp,
|
||||||
outCursor
|
outCursor
|
||||||
)
|
)
|
||||||
|
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空(常见于微信重装后)
|
||||||
|
// 自动强制重连并重试一次
|
||||||
|
if (result === -3 && outCursor[0] <= 0) {
|
||||||
|
this.writeLog('openMessageCursor: result=-3 (no message db), attempting forceReopen...', true)
|
||||||
|
const reopened = await this.forceReopen()
|
||||||
|
if (reopened && this.handle !== null) {
|
||||||
|
outCursor[0] = 0
|
||||||
|
result = this.wcdbOpenMessageCursor(
|
||||||
|
this.handle,
|
||||||
|
sessionId,
|
||||||
|
batchSize,
|
||||||
|
ascending ? 1 : 0,
|
||||||
|
beginTimestamp,
|
||||||
|
endTimestamp,
|
||||||
|
outCursor
|
||||||
|
)
|
||||||
|
this.writeLog(`openMessageCursor retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog('openMessageCursor forceReopen failed, giving up', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
if (result !== 0 || outCursor[0] <= 0) {
|
if (result !== 0 || outCursor[0] <= 0) {
|
||||||
await this.printLogs(true)
|
await this.printLogs(true)
|
||||||
this.writeLog(
|
this.writeLog(
|
||||||
`openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
|
`openMessageCursor failed: sessionId=${sessionId} batchSize=${batchSize} ascending=${ascending ? 1 : 0} begin=${beginTimestamp} end=${endTimestamp} result=${result} cursor=${outCursor[0]}`,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
return { success: false, error: `创建游标失败: ${result},请查看日志` }
|
const hint = result === -3
|
||||||
|
? `创建游标失败: ${result}(消息数据库未找到)。如果你最近重装过微信,请尝试重新指定数据目录后重试`
|
||||||
|
: `创建游标失败: ${result},请查看日志`
|
||||||
|
return { success: false, error: hint }
|
||||||
}
|
}
|
||||||
return { success: true, cursor: outCursor[0] }
|
return { success: true, cursor: outCursor[0] }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2583,7 +2681,7 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const outCursor = [0]
|
const outCursor = [0]
|
||||||
const result = this.wcdbOpenMessageCursorLite(
|
let result = this.wcdbOpenMessageCursorLite(
|
||||||
this.handle,
|
this.handle,
|
||||||
sessionId,
|
sessionId,
|
||||||
batchSize,
|
batchSize,
|
||||||
@@ -2592,6 +2690,29 @@ export class WcdbCore {
|
|||||||
endTimestamp,
|
endTimestamp,
|
||||||
outCursor
|
outCursor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// result=-3 表示 WCDB_STATUS_NO_MESSAGE_DB:消息数据库缓存为空
|
||||||
|
// 自动强制重连并重试一次
|
||||||
|
if (result === -3 && outCursor[0] <= 0) {
|
||||||
|
this.writeLog('openMessageCursorLite: result=-3 (no message db), attempting forceReopen...', true)
|
||||||
|
const reopened = await this.forceReopen()
|
||||||
|
if (reopened && this.handle !== null) {
|
||||||
|
outCursor[0] = 0
|
||||||
|
result = this.wcdbOpenMessageCursorLite(
|
||||||
|
this.handle,
|
||||||
|
sessionId,
|
||||||
|
batchSize,
|
||||||
|
ascending ? 1 : 0,
|
||||||
|
beginTimestamp,
|
||||||
|
endTimestamp,
|
||||||
|
outCursor
|
||||||
|
)
|
||||||
|
this.writeLog(`openMessageCursorLite retry after forceReopen: result=${result} cursor=${outCursor[0]}`, true)
|
||||||
|
} else {
|
||||||
|
this.writeLog('openMessageCursorLite forceReopen failed, giving up', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result !== 0 || outCursor[0] <= 0) {
|
if (result !== 0 || outCursor[0] <= 0) {
|
||||||
await this.printLogs(true)
|
await this.printLogs(true)
|
||||||
this.writeLog(
|
this.writeLog(
|
||||||
|
|||||||
4114
package-lock.json
generated
4114
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
84
package.json
84
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "2.1.0",
|
"version": "4.3.0",
|
||||||
"description": "WeFlow",
|
"description": "WeFlow",
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
"electron:build": "npm run build"
|
"electron:build": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"echarts": "^5.5.1",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"electron-store": "^10.0.0",
|
"electron-store": "^11.0.2",
|
||||||
"electron-updater": "^6.3.9",
|
"electron-updater": "^6.3.9",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"ffmpeg-static": "^5.3.0",
|
"ffmpeg-static": "^5.3.0",
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
"jieba-wasm": "^2.2.0",
|
"jieba-wasm": "^2.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.14.0",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sherpa-onnx-node": "^1.10.38",
|
"sherpa-onnx-node": "^1.10.38",
|
||||||
@@ -52,15 +52,27 @@
|
|||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"electron": "^39.2.7",
|
"electron": "^41.1.1",
|
||||||
"electron-builder": "^25.1.8",
|
"electron-builder": "^26.8.1",
|
||||||
"sass": "^1.83.0",
|
"sass": "^1.98.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^6.0.5",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-electron": "^0.28.8",
|
"vite-plugin-electron": "^0.28.8",
|
||||||
"vite-plugin-electron-renderer": "^0.14.6"
|
"vite-plugin-electron-renderer": "^0.14.6"
|
||||||
},
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"tar": ">=6.2.1",
|
||||||
|
"minimatch": ">=3.1.2",
|
||||||
|
"rollup": ">=4.0.0",
|
||||||
|
"immutable": ">=4.0.0",
|
||||||
|
"lodash": ">=4.17.21",
|
||||||
|
"brace-expansion": ">=1.1.11",
|
||||||
|
"picomatch": ">=2.3.1",
|
||||||
|
"ajv": ">=8.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.WeFlow.app",
|
"appId": "com.WeFlow.app",
|
||||||
"publish": {
|
"publish": {
|
||||||
@@ -90,18 +102,41 @@
|
|||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
],
|
],
|
||||||
"icon": "public/icon.ico"
|
"icon": "public/icon.ico",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/msvcp140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140.dll",
|
||||||
|
"to": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/vcruntime140_1.dll",
|
||||||
|
"to": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"icon": "public/icon.png",
|
"icon": "public/icon.png",
|
||||||
"target": [
|
"target": [
|
||||||
"appimage",
|
"appimage",
|
||||||
"deb",
|
|
||||||
"tar.gz"
|
"tar.gz"
|
||||||
],
|
],
|
||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"executableName": "weflow",
|
"executableName": "weflow",
|
||||||
"synopsis": "WeFlow for Linux"
|
"synopsis": "WeFlow for Linux",
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "resources/linux/install.sh",
|
||||||
|
"to": "install.sh"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -153,24 +188,11 @@
|
|||||||
"node_modules/sherpa-onnx-*/**/*",
|
"node_modules/sherpa-onnx-*/**/*",
|
||||||
"node_modules/ffmpeg-static/**/*"
|
"node_modules/ffmpeg-static/**/*"
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
|
||||||
{
|
|
||||||
"from": "resources/msvcp140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/msvcp140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140.dll",
|
|
||||||
"to": "."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "resources/vcruntime140_1.dll",
|
|
||||||
"to": "."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"icon": "resources/icon.icns"
|
"icon": "resources/icon.icns"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
|
"tar": "^7.5.13",
|
||||||
|
"immutable": "^5.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/icon.ico
BIN
public/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 364 KiB |
BIN
public/icon.png
BIN
public/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 570 KiB |
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 570 KiB |
Binary file not shown.
Binary file not shown.
BIN
resources/libwcdb_api.so
Executable file
BIN
resources/libwcdb_api.so
Executable file
Binary file not shown.
59
resources/linux/install.sh
Normal file
59
resources/linux/install.sh
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="weflow"
|
||||||
|
APP_EXEC="weflow"
|
||||||
|
OPT_DIR="/opt/$APP_NAME"
|
||||||
|
BIN_LINK="/usr/bin/$APP_NAME"
|
||||||
|
DESKTOP_DIR="/usr/share/applications"
|
||||||
|
ICON_DIR="/usr/share/pixmaps"
|
||||||
|
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "❌ 请使用 root 权限运行此脚本 (例如: sudo ./install.sh)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 开始安装 $APP_NAME..."
|
||||||
|
|
||||||
|
echo "📦 正在复制文件到 $OPT_DIR..."
|
||||||
|
rm -rf "$OPT_DIR"
|
||||||
|
mkdir -p "$OPT_DIR"
|
||||||
|
cp -r ./* "$OPT_DIR/"
|
||||||
|
chmod -R 755 "$OPT_DIR"
|
||||||
|
chmod +x "$OPT_DIR/$APP_EXEC"
|
||||||
|
|
||||||
|
echo "🔗 正在创建软链接 $BIN_LINK..."
|
||||||
|
ln -sf "$OPT_DIR/$APP_EXEC" "$BIN_LINK"
|
||||||
|
|
||||||
|
echo "📝 正在创建桌面快捷方式..."
|
||||||
|
cat <<EOF >"$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=WeFlow
|
||||||
|
Exec=$OPT_DIR/$APP_EXEC %U
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=$APP_NAME
|
||||||
|
StartupWMClass=WeFlow
|
||||||
|
Comment=A local WeChat database decryption and analysis tool
|
||||||
|
Categories=Utility;
|
||||||
|
EOF
|
||||||
|
chmod 644 "$DESKTOP_DIR/${APP_NAME}.desktop"
|
||||||
|
|
||||||
|
echo "🖼️ 正在安装图标..."
|
||||||
|
if [ -f "$OPT_DIR/resources/icon.png" ]; then
|
||||||
|
cp "$OPT_DIR/resources/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
elif [ -f "$OPT_DIR/icon.png" ]; then
|
||||||
|
cp "$OPT_DIR/icon.png" "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
chmod 644 "$ICON_DIR/${APP_NAME}.png"
|
||||||
|
else
|
||||||
|
echo "⚠️ 警告: 未找到图标文件,跳过图标安装。"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
echo "🔄 更新桌面数据库..."
|
||||||
|
update-desktop-database "$DESKTOP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ 安装完成!你现在可以在应用菜单中找到 WeFlow,或者在终端输入 'weflow' 启动。"
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/wcdb_api_arm64.dll
Normal file
BIN
resources/wcdb_api_arm64.dll
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
resources/xkey_helper_macos
Normal file
BIN
resources/xkey_helper_macos
Normal file
Binary file not shown.
25
src/App.tsx
25
src/App.tsx
@@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage'
|
|||||||
import VideoWindow from './pages/VideoWindow'
|
import VideoWindow from './pages/VideoWindow'
|
||||||
import ImageWindow from './pages/ImageWindow'
|
import ImageWindow from './pages/ImageWindow'
|
||||||
import SnsPage from './pages/SnsPage'
|
import SnsPage from './pages/SnsPage'
|
||||||
|
import BizPage from './pages/BizPage'
|
||||||
import ContactsPage from './pages/ContactsPage'
|
import ContactsPage from './pages/ContactsPage'
|
||||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||||
import NotificationWindow from './pages/NotificationWindow'
|
import NotificationWindow from './pages/NotificationWindow'
|
||||||
@@ -103,6 +104,7 @@ function App() {
|
|||||||
|
|
||||||
// 数据收集同意状态
|
// 数据收集同意状态
|
||||||
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
const [showAnalyticsConsent, setShowAnalyticsConsent] = useState(false)
|
||||||
|
const [analyticsConsent, setAnalyticsConsent] = useState<boolean | null>(null)
|
||||||
|
|
||||||
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
|
const [showWaylandWarning, setShowWaylandWarning] = useState(false)
|
||||||
|
|
||||||
@@ -252,6 +254,7 @@ function App() {
|
|||||||
// 协议已同意,检查数据收集同意状态
|
// 协议已同意,检查数据收集同意状态
|
||||||
const consent = await configService.getAnalyticsConsent()
|
const consent = await configService.getAnalyticsConsent()
|
||||||
const denyCount = await configService.getAnalyticsDenyCount()
|
const denyCount = await configService.getAnalyticsDenyCount()
|
||||||
|
setAnalyticsConsent(consent)
|
||||||
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
// 如果未设置同意状态且拒绝次数小于2次,显示弹窗
|
||||||
if (consent === null && denyCount < 2) {
|
if (consent === null && denyCount < 2) {
|
||||||
setShowAnalyticsConsent(true)
|
setShowAnalyticsConsent(true)
|
||||||
@@ -266,18 +269,21 @@ function App() {
|
|||||||
checkAgreement()
|
checkAgreement()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 初始化数据收集
|
// 初始化数据收集(仅在用户同意后)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (analyticsConsent === true) {
|
||||||
cloudControl.initCloudControl()
|
cloudControl.initCloudControl()
|
||||||
}, [])
|
}
|
||||||
|
}, [analyticsConsent])
|
||||||
|
|
||||||
// 记录页面访问
|
// 记录页面访问(仅在用户同意后)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (analyticsConsent !== true) return
|
||||||
const path = location.pathname
|
const path = location.pathname
|
||||||
if (path && path !== '/') {
|
if (path && path !== '/') {
|
||||||
cloudControl.recordPage(path)
|
cloudControl.recordPage(path)
|
||||||
}
|
}
|
||||||
}, [location.pathname])
|
}, [location.pathname, analyticsConsent])
|
||||||
|
|
||||||
const handleAgree = async () => {
|
const handleAgree = async () => {
|
||||||
if (!agreementChecked) return
|
if (!agreementChecked) return
|
||||||
@@ -296,6 +302,7 @@ function App() {
|
|||||||
|
|
||||||
const handleAnalyticsAllow = async () => {
|
const handleAnalyticsAllow = async () => {
|
||||||
await configService.setAnalyticsConsent(true)
|
await configService.setAnalyticsConsent(true)
|
||||||
|
setAnalyticsConsent(true)
|
||||||
setShowAnalyticsConsent(false)
|
setShowAnalyticsConsent(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,10 +319,14 @@ function App() {
|
|||||||
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
|
||||||
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
// 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
|
||||||
if (info) {
|
if (info) {
|
||||||
setUpdateInfo({ ...info, hasUpdate: true })
|
window.electronAPI.app.getVersion().then((currentVersion: string) => {
|
||||||
|
const isMandatory = !!(info.minimumVersion && currentVersion &&
|
||||||
|
currentVersion.localeCompare(info.minimumVersion, undefined, { numeric: true, sensitivity: 'base' }) <= 0)
|
||||||
|
setUpdateInfo({ ...info, hasUpdate: true, isMandatory })
|
||||||
if (!useAppStore.getState().isLocked) {
|
if (!useAppStore.getState().isLocked) {
|
||||||
setShowUpdateDialog(true)
|
setShowUpdateDialog(true)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
|
||||||
@@ -685,10 +696,11 @@ function App() {
|
|||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
open={showUpdateDialog}
|
open={showUpdateDialog}
|
||||||
updateInfo={updateInfo}
|
updateInfo={updateInfo}
|
||||||
onClose={() => setShowUpdateDialog(false)}
|
onClose={() => { if (!(updateInfo as any)?.isMandatory) setShowUpdateDialog(false) }}
|
||||||
onUpdate={handleUpdateNow}
|
onUpdate={handleUpdateNow}
|
||||||
onIgnore={handleIgnoreUpdate}
|
onIgnore={handleIgnoreUpdate}
|
||||||
isDownloading={isDownloading}
|
isDownloading={isDownloading}
|
||||||
|
isMandatory={!!(updateInfo as any)?.isMandatory}
|
||||||
progress={downloadProgress}
|
progress={downloadProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -725,6 +737,7 @@ function App() {
|
|||||||
|
|
||||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||||
<Route path="/sns" element={<SnsPage />} />
|
<Route path="/sns" element={<SnsPage />} />
|
||||||
|
<Route path="/biz" element={<BizPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
&.clickable {
|
&.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -172,6 +174,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.year-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.year-btn {
|
||||||
|
padding: 10px 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
|
||||||
import './JumpToDateDialog.scss'
|
import './JumpToDateDialog.scss'
|
||||||
|
|
||||||
@@ -21,10 +21,15 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
messageDates,
|
messageDates,
|
||||||
loadingDates = false
|
loadingDates = false
|
||||||
}) => {
|
}) => {
|
||||||
|
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||||
|
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||||
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
|
||||||
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
|
||||||
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
|
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||||
|
const [yearPageStart, setYearPageStart] = useState<number>(
|
||||||
|
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
|
||||||
|
)
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
@@ -116,6 +121,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
|
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
const days = generateCalendar()
|
||||||
|
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
|
||||||
|
|
||||||
|
const updateCalendarDate = (nextDate: Date) => {
|
||||||
|
setCalendarDate(nextDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMonthView = () => setViewMode('month')
|
||||||
|
const openYearView = () => {
|
||||||
|
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||||
|
setViewMode('year')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTitleClick = () => {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
openMonthView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
openYearView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setYearPageStart((prev) => prev - 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setYearPageStart((prev) => prev + 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navTitle = viewMode === 'day'
|
||||||
|
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||||
|
: viewMode === 'month'
|
||||||
|
? `${calendarDate.getFullYear()}年`
|
||||||
|
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jump-date-overlay" onClick={onClose}>
|
<div className="jump-date-overlay" onClick={onClose}>
|
||||||
@@ -134,45 +190,57 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
onClick={handlePrev}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
|
<button
|
||||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||||
</span>
|
onClick={handleTitleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{navTitle}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
onClick={handleNext}
|
||||||
>
|
>
|
||||||
<ChevronRight size={18} />
|
<ChevronRight size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showYearMonthPicker ? (
|
{viewMode === 'month' ? (
|
||||||
<div className="year-month-picker">
|
<div className="year-month-picker">
|
||||||
<div className="year-selector">
|
|
||||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</button>
|
|
||||||
<span className="year-label">{calendarDate.getFullYear()}年</span>
|
|
||||||
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
|
|
||||||
<ChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="month-grid">
|
<div className="month-grid">
|
||||||
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
|
{monthNames.map((name, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
|
||||||
setShowYearMonthPicker(false)
|
setViewMode('day')
|
||||||
}}
|
}}
|
||||||
>{name}</button>
|
>{name}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : viewMode === 'year' ? (
|
||||||
|
<div className="year-month-picker">
|
||||||
|
<div className="year-grid">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||||
|
setViewMode('month')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{year}年
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
|
||||||
{loadingDates && (
|
{loadingDates && (
|
||||||
@@ -208,18 +276,21 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
|
|||||||
const d = new Date()
|
const d = new Date()
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>今天</button>
|
}}>今天</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setDate(d.getDate() - 7)
|
d.setDate(d.getDate() - 7)
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>一周前</button>
|
}}>一周前</button>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
const d = new Date()
|
const d = new Date()
|
||||||
d.setMonth(d.getMonth() - 1)
|
d.setMonth(d.getMonth() - 1)
|
||||||
setSelectedDate(d)
|
setSelectedDate(d)
|
||||||
setCalendarDate(new Date(d))
|
setCalendarDate(new Date(d))
|
||||||
|
setViewMode('day')
|
||||||
}}>一月前</button>
|
}}>一月前</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,20 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .current-month.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .current-month.clickable:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.jump-date-popover .nav-btn {
|
.jump-date-popover .nav-btn {
|
||||||
@@ -83,6 +97,37 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .month-grid,
|
||||||
|
.jump-date-popover .year-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .month-cell,
|
||||||
|
.jump-date-popover .year-cell {
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .month-cell:hover,
|
||||||
|
.jump-date-popover .year-cell:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-date-popover .month-cell.active,
|
||||||
|
.jump-date-popover .year-cell.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.jump-date-popover .day-cell {
|
.jump-date-popover .day-cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
|||||||
@@ -31,14 +31,20 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
loadingDates = false,
|
loadingDates = false,
|
||||||
loadingDateCounts = false
|
loadingDateCounts = false
|
||||||
}) => {
|
}) => {
|
||||||
|
type CalendarViewMode = 'day' | 'month' | 'year'
|
||||||
|
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
|
||||||
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
|
||||||
|
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
|
||||||
|
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const normalized = new Date(currentDate)
|
const normalized = new Date(currentDate)
|
||||||
setCalendarDate(normalized)
|
setCalendarDate(normalized)
|
||||||
setSelectedDate(normalized)
|
setSelectedDate(normalized)
|
||||||
|
setViewMode('day')
|
||||||
|
setYearPageStart(getYearPageStart(normalized.getFullYear()))
|
||||||
}, [isOpen, currentDate])
|
}, [isOpen, currentDate])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
@@ -114,25 +120,78 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const days = generateCalendar()
|
const days = generateCalendar()
|
||||||
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
|
||||||
|
|
||||||
const updateCalendarDate = (nextDate: Date) => {
|
const updateCalendarDate = (nextDate: Date) => {
|
||||||
setCalendarDate(nextDate)
|
setCalendarDate(nextDate)
|
||||||
onMonthChange?.(nextDate)
|
onMonthChange?.(nextDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openMonthView = () => setViewMode('month')
|
||||||
|
const openYearView = () => {
|
||||||
|
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
|
||||||
|
setViewMode('year')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTitleClick = () => {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
openMonthView()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
openYearView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setYearPageStart((prev) => prev - 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (viewMode === 'day') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (viewMode === 'month') {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setYearPageStart((prev) => prev + 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
const navTitle = viewMode === 'day'
|
||||||
|
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
|
||||||
|
: viewMode === 'month'
|
||||||
|
? `${calendarDate.getFullYear()}年`
|
||||||
|
: `${yearPageStart}年 - ${yearPageStart + 11}年`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
|
||||||
<div className="calendar-nav">
|
<div className="calendar-nav">
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
onClick={handlePrev}
|
||||||
aria-label="上一月"
|
aria-label="上一月"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span className="current-month">{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月</span>
|
<button
|
||||||
|
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
|
||||||
|
onClick={handleTitleClick}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{navTitle}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="nav-btn"
|
className="nav-btn"
|
||||||
onClick={() => updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
onClick={handleNext}
|
||||||
aria-label="下一月"
|
aria-label="下一月"
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
@@ -154,6 +213,7 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'day' && (
|
||||||
<div className="calendar-grid">
|
<div className="calendar-grid">
|
||||||
<div className="weekdays">
|
<div className="weekdays">
|
||||||
{weekdays.map(day => (
|
{weekdays.map(day => (
|
||||||
@@ -184,6 +244,43 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'month' && (
|
||||||
|
<div className="month-grid">
|
||||||
|
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
|
||||||
|
setViewMode('day')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{viewMode === 'year' && (
|
||||||
|
<div className="year-grid">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
|
||||||
|
<button
|
||||||
|
key={year}
|
||||||
|
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
|
||||||
|
setViewMode('month')
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{year}年
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,3 +283,12 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mandatory-tip {
|
||||||
|
color: #e53e3e;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(229, 62, 62, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface UpdateDialogProps {
|
|||||||
onUpdate: () => void
|
onUpdate: () => void
|
||||||
onIgnore?: () => void
|
onIgnore?: () => void
|
||||||
isDownloading: boolean
|
isDownloading: boolean
|
||||||
|
isMandatory?: boolean
|
||||||
progress: number | {
|
progress: number | {
|
||||||
percent: number
|
percent: number
|
||||||
bytesPerSecond?: number
|
bytesPerSecond?: number
|
||||||
@@ -30,6 +31,7 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
onIgnore,
|
onIgnore,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
|
isMandatory,
|
||||||
progress
|
progress
|
||||||
}) => {
|
}) => {
|
||||||
if (!open || !updateInfo) return null
|
if (!open || !updateInfo) return null
|
||||||
@@ -69,7 +71,7 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="update-dialog-overlay">
|
<div className="update-dialog-overlay">
|
||||||
<div className="update-dialog">
|
<div className="update-dialog">
|
||||||
{!isDownloading && (
|
{!isDownloading && !isMandatory && (
|
||||||
<button className="close-btn" onClick={onClose}>
|
<button className="close-btn" onClick={onClose}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -119,11 +121,14 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
{onIgnore && (
|
{onIgnore && !isMandatory && (
|
||||||
<button className="btn-ignore" onClick={onIgnore}>
|
<button className="btn-ignore" onClick={onIgnore}>
|
||||||
忽略本次更新
|
忽略本次更新
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isMandatory && (
|
||||||
|
<p className="mandatory-tip">此版本存在安全风险,必须更新后才能继续使用</p>
|
||||||
|
)}
|
||||||
<button className="btn-update" onClick={onUpdate}>
|
<button className="btn-update" onClick={onUpdate}>
|
||||||
开启新旅程
|
开启新旅程
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
345
src/pages/BizPage.scss
Normal file
345
src/pages/BizPage.scss
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
.biz-account-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--bg-secondary); // 对齐会话列表背景
|
||||||
|
|
||||||
|
.biz-loading {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-account-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--primary-light) !important;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: 13px; // 补偿 border-left
|
||||||
|
}
|
||||||
|
|
||||||
|
&.pay-account {
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
&.active {
|
||||||
|
background-color: var(--primary-light) !important;
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px; // 对齐会话列表头像圆角
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.biz-info-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.biz-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 2px;
|
||||||
|
|
||||||
|
&.type-service { color: #07c160; background: rgba(7, 193, 96, 0.1); }
|
||||||
|
&.type-sub { color: var(--primary); background: var(--primary-light); }
|
||||||
|
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
|
||||||
|
&.type-unknown { color: var(--text-tertiary); background: var(--bg-tertiary); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-main {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-secondary); // 对齐聊天页背景
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: var(--chat-pattern);
|
||||||
|
background-color: var(--bg-tertiary); // 对齐聊天背景色
|
||||||
|
|
||||||
|
.messages-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 占位状态:对齐 Chat 页面风格
|
||||||
|
.biz-no-record-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
.no-record-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.5;
|
||||||
|
|
||||||
|
svg { width: 32px; height: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 280px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-loading-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
.pay-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.pay-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.pay-icon-placeholder {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #07c160;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||||
|
|
||||||
|
.main-article {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.article-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
object-fit: cover;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||||
|
|
||||||
|
.article-title {
|
||||||
|
color: white;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-digest {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-articles {
|
||||||
|
.sub-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background-color: var(--bg-hover); }
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding-right: 12px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-cover {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-empty-state {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-tertiary); // 对齐 Chat 页面空白背景
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--bg-secondary);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
|
||||||
|
svg { width: 40px; height: 40px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
p { color: var(--text-tertiary); font-size: 14px; }
|
||||||
|
}
|
||||||
261
src/pages/BizPage.tsx
Normal file
261
src/pages/BizPage.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useThemeStore } from '../stores/themeStore';
|
||||||
|
import { Newspaper, MessageSquareOff } from 'lucide-react';
|
||||||
|
import './BizPage.scss';
|
||||||
|
|
||||||
|
export interface BizAccount {
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
avatar: string;
|
||||||
|
type: number;
|
||||||
|
last_time: number;
|
||||||
|
formatted_last_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BizAccountList: React.FC<{
|
||||||
|
onSelect: (account: BizAccount) => void;
|
||||||
|
selectedUsername?: string;
|
||||||
|
searchKeyword?: string;
|
||||||
|
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
|
||||||
|
const [accounts, setAccounts] = useState<BizAccount[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [myWxid, setMyWxid] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initWxid = async () => {
|
||||||
|
try {
|
||||||
|
const wxid = await window.electronAPI.config.get('myWxid');
|
||||||
|
if (wxid) {
|
||||||
|
setMyWxid(wxid as string);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("获取 myWxid 失败:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initWxid().then(_r => { });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch = async () => {
|
||||||
|
if (!myWxid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await window.electronAPI.biz.listAccounts(myWxid)
|
||||||
|
setAccounts(res || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取服务号列表失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetch().then(_r => { } );
|
||||||
|
}, [myWxid]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!searchKeyword) return accounts;
|
||||||
|
const q = searchKeyword.toLowerCase();
|
||||||
|
return accounts.filter(a =>
|
||||||
|
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||||
|
(a.username && a.username.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [accounts, searchKeyword]);
|
||||||
|
|
||||||
|
if (loading) return <div className="biz-loading">加载中...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="biz-account-list">
|
||||||
|
{filtered.map(item => (
|
||||||
|
<div
|
||||||
|
key={item.username}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.avatar}
|
||||||
|
className="biz-avatar"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div className="biz-info">
|
||||||
|
<div className="biz-info-top">
|
||||||
|
<span className="biz-name">{item.name || item.username}</span>
|
||||||
|
<span className="biz-time">{item.formatted_last_time}</span>
|
||||||
|
</div>
|
||||||
|
{item.username === 'gh_3dfda90e39d6' && (
|
||||||
|
<div className="biz-badge type-service">服务号</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 我看了下没有接口获取相关type,如果exec没法用的话确实无能为力,后面再适配吧 */}
|
||||||
|
{/*<div className={`biz-badge ${*/}
|
||||||
|
{/* item.type === 1 ? 'type-service' :*/}
|
||||||
|
{/* item.type === 0 ? 'type-sub' :*/}
|
||||||
|
{/* item.type === 2 ? 'type-enterprise' : 'type-unknown'*/}
|
||||||
|
{/*}`}>*/}
|
||||||
|
{/* {item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}*/}
|
||||||
|
{/*</div>*/}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 公众号消息区域组件
|
||||||
|
export const BizMessageArea: React.FC<{
|
||||||
|
account: BizAccount | null;
|
||||||
|
}> = ({ account }) => {
|
||||||
|
const themeMode = useThemeStore((state) => state.themeMode);
|
||||||
|
const [messages, setMessages] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const limit = 20;
|
||||||
|
const messageListRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [myWxid, setMyWxid] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initWxid = async () => {
|
||||||
|
try {
|
||||||
|
const wxid = await window.electronAPI.config.get('myWxid');
|
||||||
|
if (wxid) {
|
||||||
|
setMyWxid(wxid as string);
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
};
|
||||||
|
initWxid();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isDark = useMemo(() => {
|
||||||
|
if (themeMode === 'dark') return true;
|
||||||
|
if (themeMode === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account && myWxid) {
|
||||||
|
setMessages([]);
|
||||||
|
setOffset(0);
|
||||||
|
setHasMore(true);
|
||||||
|
loadMessages(account.username, 0);
|
||||||
|
}
|
||||||
|
}, [account, myWxid]);
|
||||||
|
|
||||||
|
const loadMessages = async (username: string, currentOffset: number) => {
|
||||||
|
if (loading || !myWxid) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (username === 'gh_3dfda90e39d6') {
|
||||||
|
res = await window.electronAPI.biz.listPayRecords(myWxid, limit, currentOffset);
|
||||||
|
} else {
|
||||||
|
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
|
||||||
|
}
|
||||||
|
if (res) {
|
||||||
|
if (res.length < limit) setHasMore(false);
|
||||||
|
setMessages(prev => currentOffset === 0 ? res : [...prev, ...res]);
|
||||||
|
setOffset(currentOffset + limit);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载消息失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
|
||||||
|
if (!loading && hasMore && account) {
|
||||||
|
loadMessages(account.username, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<div className="biz-empty-state">
|
||||||
|
<div className="empty-icon"><Newspaper size={40} /></div>
|
||||||
|
<p>请选择一个服务号查看消息</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
|
||||||
|
<div className="main-header">
|
||||||
|
<h2>{account.name}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
|
||||||
|
<div className="messages-wrapper">
|
||||||
|
{!loading && messages.length === 0 && (
|
||||||
|
<div className="biz-no-record-container">
|
||||||
|
<div className="no-record-icon">
|
||||||
|
<MessageSquareOff size={48} />
|
||||||
|
</div>
|
||||||
|
<h3>暂无本地记录</h3>
|
||||||
|
<p>该公众号在当前数据库中没有可显示的聊天历史</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.local_id}>
|
||||||
|
{account.username === 'gh_3dfda90e39d6' ? (
|
||||||
|
<div className="pay-card">
|
||||||
|
<div className="pay-header">
|
||||||
|
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>}
|
||||||
|
<span>{msg.merchant_name || '微信支付'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pay-title">{msg.title}</div>
|
||||||
|
<div className="pay-desc">{msg.description}</div>
|
||||||
|
<div className="pay-footer">{msg.formatted_time}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="article-card">
|
||||||
|
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
|
||||||
|
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
|
||||||
|
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
|
||||||
|
</div>
|
||||||
|
{msg.des && <div className="article-digest">{msg.des}</div>}
|
||||||
|
{msg.content_list && msg.content_list.length > 1 && (
|
||||||
|
<div className="sub-articles">
|
||||||
|
{msg.content_list.slice(1).map((item: any, idx: number) => (
|
||||||
|
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
|
||||||
|
<span className="sub-title">{item.title}</span>
|
||||||
|
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{loading && <div className="biz-loading-more">加载中...</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BizPage: React.FC = () => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
|
||||||
|
return (
|
||||||
|
<div className="biz-page">
|
||||||
|
<div className="biz-sidebar">
|
||||||
|
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
|
||||||
|
</div>
|
||||||
|
<BizMessageArea account={selectedAccount} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BizPage;
|
||||||
@@ -4487,6 +4487,32 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公众号入口样式
|
||||||
|
.session-item.biz-entry {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover-bg, rgba(0,0,0,0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.biz-entry-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
// 消息信息弹窗
|
// 消息信息弹窗
|
||||||
.message-info-overlay {
|
.message-info-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
|
||||||
@@ -16,6 +16,7 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
|
|||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import BizPage, { BizAccountList, BizMessageArea, BizAccount } from './BizPage'
|
||||||
import {
|
import {
|
||||||
finishBackgroundTask,
|
finishBackgroundTask,
|
||||||
isBackgroundTaskCancelRequested,
|
isBackgroundTaskCancelRequested,
|
||||||
@@ -36,6 +37,8 @@ const SYSTEM_MESSAGE_TYPES = [
|
|||||||
266287972401, // 拍一拍
|
266287972401, // 拍一拍
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const OFFICIAL_ACCOUNTS_VIRTUAL_ID = 'official_accounts_virtual'
|
||||||
|
|
||||||
interface PendingInSessionSearchPayload {
|
interface PendingInSessionSearchPayload {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
keyword: string
|
keyword: string
|
||||||
@@ -983,6 +986,7 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
||||||
|
const isBizEntry = session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID
|
||||||
|
|
||||||
// 折叠入口:专属名称和图标
|
// 折叠入口:专属名称和图标
|
||||||
if (isFoldEntry) {
|
if (isFoldEntry) {
|
||||||
@@ -1007,6 +1011,29 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 公众号入口:专属名称和图标
|
||||||
|
if (isBizEntry) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`session-item biz-entry ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => onSelect(session)}
|
||||||
|
>
|
||||||
|
<div className="biz-entry-avatar">
|
||||||
|
<Newspaper size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="session-top">
|
||||||
|
<span className="session-name">订阅号/服务号</span>
|
||||||
|
<span className="session-time">{timeText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-bottom">
|
||||||
|
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 根据匹配字段显示不同的 summary
|
// 根据匹配字段显示不同的 summary
|
||||||
const summaryContent = useMemo(() => {
|
const summaryContent = useMemo(() => {
|
||||||
if (session.matchedField === 'wxid') {
|
if (session.matchedField === 'wxid') {
|
||||||
@@ -1204,6 +1231,8 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
||||||
|
const [bizView, setBizView] = useState(false) // 是否在"公众号"视图
|
||||||
|
const [selectedBizAccount, setSelectedBizAccount] = useState<BizAccount | null>(null)
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
|
||||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
@@ -2691,6 +2720,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setConnected(false)
|
setConnected(false)
|
||||||
setConnecting(false)
|
setConnecting(false)
|
||||||
setHasMoreMessages(true)
|
setHasMoreMessages(true)
|
||||||
|
setFoldedView(false)
|
||||||
|
setBizView(false)
|
||||||
|
setSelectedBizAccount(null)
|
||||||
setHasMoreLater(false)
|
setHasMoreLater(false)
|
||||||
const scope = await resolveChatCacheScope()
|
const scope = await resolveChatCacheScope()
|
||||||
hydrateSessionListCache(scope)
|
hydrateSessionListCache(scope)
|
||||||
@@ -3227,7 +3259,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const session = sessionMapRef.current.get(sessionId)
|
const session = sessionMapRef.current.get(sessionId)
|
||||||
const unreadCount = session?.unreadCount ?? 0
|
const unreadCount = session?.unreadCount ?? 0
|
||||||
|
|
||||||
let messageLimit = currentBatchSizeRef.current
|
let messageLimit: number
|
||||||
|
|
||||||
if (offset === 0) {
|
if (offset === 0) {
|
||||||
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
const preferredLimit = Number.isFinite(options.forceInitialLimit)
|
||||||
@@ -3964,6 +3996,12 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setFoldedView(true)
|
setFoldedView(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 点击公众号入口,切换到公众号视图
|
||||||
|
if (session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID) {
|
||||||
|
setBizView(true)
|
||||||
|
setSelectedBizAccount(null) // 切入时默认不选中任何公众号
|
||||||
|
return
|
||||||
|
}
|
||||||
selectSessionById(session.username)
|
selectSessionById(session.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4946,14 +4984,30 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
|
||||||
const hasFoldedGroups = foldedGroups.length > 0
|
const hasFoldedGroups = foldedGroups.length > 0
|
||||||
|
|
||||||
const visible = sessions.filter(s => {
|
let visible = sessions.filter(s => {
|
||||||
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果有折叠的群聊,但列表中没有入口,则插入入口
|
const bizEntry: ChatSession = {
|
||||||
|
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
|
||||||
|
displayName: '公众号',
|
||||||
|
summary: '查看公众号历史消息',
|
||||||
|
type: 0,
|
||||||
|
sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下
|
||||||
|
lastTimestamp: 0,
|
||||||
|
lastMsgType: 0,
|
||||||
|
unreadCount: 0,
|
||||||
|
isMuted: false,
|
||||||
|
isFolded: false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
|
||||||
|
visible.unshift(bizEntry)
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
|
||||||
// 找到最新的折叠消息
|
|
||||||
const latestFolded = foldedGroups.reduce((latest, current) => {
|
const latestFolded = foldedGroups.reduce((latest, current) => {
|
||||||
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
const latestTime = latest.sortTimestamp || latest.lastTimestamp
|
||||||
const currentTime = current.sortTimestamp || current.lastTimestamp
|
const currentTime = current.sortTimestamp || current.lastTimestamp
|
||||||
@@ -6031,7 +6085,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
|
<div className={`session-header session-header-viewport ${foldedView || bizView ? 'folded' : ''}`}>
|
||||||
{/* 普通 header */}
|
{/* 普通 header */}
|
||||||
<div className="session-header-panel main-header">
|
<div className="session-header-panel main-header">
|
||||||
<div className="search-row">
|
<div className="search-row">
|
||||||
@@ -6061,12 +6115,18 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
{/* 折叠群 header */}
|
{/* 折叠群 header */}
|
||||||
<div className="session-header-panel folded-header">
|
<div className="session-header-panel folded-header">
|
||||||
<div className="folded-view-header">
|
<div className="folded-view-header">
|
||||||
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
|
<button className="icon-btn back-btn" onClick={() => {
|
||||||
|
setFoldedView(false)
|
||||||
|
setBizView(false)
|
||||||
|
}}>
|
||||||
<ChevronLeft size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
<span className="folded-view-title">
|
<span className="folded-view-title">
|
||||||
<Users size={14} />
|
{foldedView ? (
|
||||||
折叠的群聊
|
<><Users size={14} /> 折叠的群聊</>
|
||||||
|
) : bizView ? (
|
||||||
|
<><Newspaper size={14} /> 订阅号/服务号</>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6173,7 +6233,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
|
<div className={`session-list-viewport ${foldedView || bizView ? 'folded' : ''}`}>
|
||||||
{/* 普通会话列表 */}
|
{/* 普通会话列表 */}
|
||||||
<div className="session-list-panel main-panel">
|
<div className="session-list-panel main-panel">
|
||||||
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
||||||
@@ -6199,7 +6259,7 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<SessionItem
|
<SessionItem
|
||||||
key={session.username}
|
key={session.username}
|
||||||
session={session}
|
session={session}
|
||||||
isActive={currentSessionId === session.username}
|
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
formatTime={formatSessionTime}
|
formatTime={formatSessionTime}
|
||||||
searchKeyword={searchKeyword}
|
searchKeyword={searchKeyword}
|
||||||
@@ -6218,13 +6278,14 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
{/* 折叠群列表 */}
|
{/* 折叠群列表 */}
|
||||||
<div className="session-list-panel folded-panel">
|
<div className="session-list-panel folded-panel">
|
||||||
{foldedSessions.length > 0 ? (
|
{foldedView && (
|
||||||
|
foldedSessions.length > 0 ? (
|
||||||
<div className="session-list">
|
<div className="session-list">
|
||||||
{foldedSessions.map(session => (
|
{foldedSessions.map(session => (
|
||||||
<SessionItem
|
<SessionItem
|
||||||
key={session.username}
|
key={session.username}
|
||||||
session={session}
|
session={session}
|
||||||
isActive={currentSessionId === session.username}
|
isActive={currentSessionId === session.username || (bizView && session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)}
|
||||||
onSelect={handleSelectSession}
|
onSelect={handleSelectSession}
|
||||||
formatTime={formatSessionTime}
|
formatTime={formatSessionTime}
|
||||||
searchKeyword={searchKeyword}
|
searchKeyword={searchKeyword}
|
||||||
@@ -6236,6 +6297,17 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
<Users size={32} />
|
<Users size={32} />
|
||||||
<p>没有折叠的群聊</p>
|
<p>没有折叠的群聊</p>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bizView && (
|
||||||
|
<div style={{ height: '100%', overflowY: 'auto' }}>
|
||||||
|
<BizAccountList
|
||||||
|
onSelect={setSelectedBizAccount}
|
||||||
|
selectedUsername={selectedBizAccount?.username}
|
||||||
|
searchKeyword={searchKeyword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -6247,7 +6319,9 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
|
|
||||||
{/* 右侧消息区域 */}
|
{/* 右侧消息区域 */}
|
||||||
<div className="message-area">
|
<div className="message-area">
|
||||||
{currentSession ? (
|
{bizView ? (
|
||||||
|
<BizMessageArea account={selectedBizAccount} />
|
||||||
|
) : currentSession ? (
|
||||||
<>
|
<>
|
||||||
<div className="message-header">
|
<div className="message-header">
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -7901,7 +7975,7 @@ function MessageBubble({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emojiLocalPath) return
|
if (emojiLocalPath) return
|
||||||
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
|
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
|
||||||
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
|
if (isEmoji && message.emojiLocalPath) {
|
||||||
captureEmojiResizeBaseline()
|
captureEmojiResizeBaseline()
|
||||||
setEmojiLocalPath(message.emojiLocalPath)
|
setEmojiLocalPath(message.emojiLocalPath)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
|
|||||||
const SEARCH_DEBOUNCE_MS = 120
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
const VIRTUAL_ROW_HEIGHT = 76
|
const VIRTUAL_ROW_HEIGHT = 76
|
||||||
const VIRTUAL_OVERSCAN = 10
|
const VIRTUAL_OVERSCAN = 10
|
||||||
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
|
||||||
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
interface ContactsLoadSession {
|
interface ContactsLoadSession {
|
||||||
@@ -397,6 +397,10 @@ function ContactsPage() {
|
|||||||
displayName: contact.displayName,
|
displayName: contact.displayName,
|
||||||
remark: contact.remark,
|
remark: contact.remark,
|
||||||
nickname: contact.nickname,
|
nickname: contact.nickname,
|
||||||
|
alias: contact.alias,
|
||||||
|
labels: contact.labels,
|
||||||
|
detailDescription: contact.detailDescription,
|
||||||
|
region: contact.region,
|
||||||
type: contact.type
|
type: contact.type
|
||||||
}))
|
}))
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
@@ -1110,6 +1114,16 @@ function ContactsPage() {
|
|||||||
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||||||
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||||||
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||||||
|
{selectedContact.alias && <div className="detail-row"><span className="detail-label">微信号</span><span className="detail-value">{selectedContact.alias}</span></div>}
|
||||||
|
{selectedContact.labels && selectedContact.labels.length > 0 && (
|
||||||
|
<div className="detail-row"><span className="detail-label">标签</span><span className="detail-value">{selectedContact.labels.join('、')}</span></div>
|
||||||
|
)}
|
||||||
|
{selectedContact.detailDescription && (
|
||||||
|
<div className="detail-row"><span className="detail-label">个性签名</span><span className="detail-value">{selectedContact.detailDescription}</span></div>
|
||||||
|
)}
|
||||||
|
{selectedContact.region && (
|
||||||
|
<div className="detail-row"><span className="detail-label">地区</span><span className="detail-value">{selectedContact.region}</span></div>
|
||||||
|
)}
|
||||||
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||||||
{selectedContactSupportsSns && (
|
{selectedContactSupportsSns && (
|
||||||
<div className="detail-row">
|
<div className="detail-row">
|
||||||
|
|||||||
@@ -568,7 +568,7 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(
|
|||||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||||
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
||||||
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
||||||
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 10000
|
||||||
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
|
const EXPORT_REENTER_SESSION_SOFT_REFRESH_MS = 5 * 60 * 1000
|
||||||
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
|
const EXPORT_REENTER_CONTACTS_SOFT_REFRESH_MS = 5 * 60 * 1000
|
||||||
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
|
const EXPORT_REENTER_SNS_SOFT_REFRESH_MS = 3 * 60 * 1000
|
||||||
@@ -1928,7 +1928,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
setIsContactsListLoading(true)
|
setIsContactsListLoading(true)
|
||||||
try {
|
try {
|
||||||
const contactsResult = await window.electronAPI.chat.getContacts()
|
const contactsResult = await window.electronAPI.chat.getContacts({ lite: true })
|
||||||
if (contactsLoadVersionRef.current !== loadVersion) return
|
if (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
|
|
||||||
if (contactsResult.success && contactsResult.contacts) {
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
@@ -3782,7 +3782,7 @@ function ExportPage() {
|
|||||||
|
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
if (detailStatsPriorityRef.current) return
|
if (detailStatsPriorityRef.current) return
|
||||||
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
|
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts({ lite: true }), CONTACT_ENRICH_TIMEOUT_MS)
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
|
|
||||||
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
|
const contactsFromNetwork: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
|
||||||
|
|||||||
@@ -834,11 +834,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.member-export-panel,
|
.member-export-panel,
|
||||||
.member-messages-panel {
|
.member-messages-panel,
|
||||||
|
.member-analytics-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.member-export-empty {
|
.member-export-empty {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -1521,29 +1523,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-cards {
|
.stats-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
|
padding-top: 10px;
|
||||||
.stat-card {
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.value {
|
|
||||||
display: block;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown, MessageSquare, Calendar, PieChart, Hash, Smile } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
@@ -37,7 +37,7 @@ interface GroupMessageRank {
|
|||||||
messageCount: number
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalysisFunction = 'members' | 'memberMessages' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'memberMessages' | 'memberAnalytics' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone'
|
||||||
|
|
||||||
interface MemberMessageExportOptions {
|
interface MemberMessageExportOptions {
|
||||||
@@ -167,6 +167,8 @@ function GroupAnalyticsPage() {
|
|||||||
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
const [isExportingMembers, setIsExportingMembers] = useState(false)
|
||||||
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
|
||||||
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
const [memberMessages, setMemberMessages] = useState<Message[]>([])
|
||||||
|
const [memberAnalyticsData, setMemberAnalyticsData] = useState<any | null>(null)
|
||||||
|
const [analyticsError, setAnalyticsError] = useState<string | null>(null)
|
||||||
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
const [memberMessagesHasMore, setMemberMessagesHasMore] = useState(false)
|
||||||
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
const [memberMessagesCursor, setMemberMessagesCursor] = useState(0)
|
||||||
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
const [memberMessagesLoadingMore, setMemberMessagesLoadingMore] = useState(false)
|
||||||
@@ -524,6 +526,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'memberMessages': {
|
case 'memberMessages': {
|
||||||
|
resetMemberMessageState(false)
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在读取成员列表与消息',
|
detail: '正在读取成员列表与消息',
|
||||||
progressText: '成员消息'
|
progressText: '成员消息'
|
||||||
@@ -566,7 +569,57 @@ function GroupAnalyticsPage() {
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'memberAnalytics': {
|
||||||
|
setMemberAnalyticsData(null)
|
||||||
|
setAnalyticsError(null)
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取成员列表与消息分析',
|
||||||
|
progressText: '成员分析'
|
||||||
|
})
|
||||||
|
const result = await window.electronAPI.groupAnalytics.getGroupMembers(targetGroup.username)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: result.error || '获取成员列表失败' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setMembers(result.data)
|
||||||
|
let targetMember = preferredMemberUsername
|
||||||
|
? result.data.find(m => m.username === preferredMemberUsername)
|
||||||
|
: result.data.find(m => m.username === selectedMessageMemberUsername)
|
||||||
|
if (!targetMember && result.data.length > 0) {
|
||||||
|
targetMember = result.data[0]
|
||||||
|
setSelectedMessageMemberUsername(targetMember.username)
|
||||||
|
}
|
||||||
|
if (!targetMember) {
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: '找不到目标成员' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: `正在分析 ${targetMember.displayName || targetMember.username} 的发言记录`,
|
||||||
|
progressText: '统计分析'
|
||||||
|
})
|
||||||
|
const analyticsResult = await window.electronAPI.groupAnalytics.getGroupMemberAnalytics(targetGroup.username, targetMember.username, startTime, endTime)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员分析未继续写入' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (analyticsResult.success && analyticsResult.data) {
|
||||||
|
setMemberAnalyticsData(analyticsResult.data)
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: `分析完成,共计 ${analyticsResult.data.statistics?.totalMessages || 0} 条消息`,
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setAnalyticsError(analyticsResult.error || '分析失败')
|
||||||
|
finishBackgroundTask(taskId, 'failed', { detail: analyticsResult.error || '分析失败' })
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
|
setRankings([])
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在计算群消息排行',
|
detail: '正在计算群消息排行',
|
||||||
progressText: '消息排行'
|
progressText: '消息排行'
|
||||||
@@ -584,6 +637,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'activeHours': {
|
case 'activeHours': {
|
||||||
|
setActiveHours({})
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在计算群活跃时段',
|
detail: '正在计算群活跃时段',
|
||||||
progressText: '活跃时段'
|
progressText: '活跃时段'
|
||||||
@@ -601,6 +655,7 @@ function GroupAnalyticsPage() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'mediaStats': {
|
case 'mediaStats': {
|
||||||
|
setMediaStats(null)
|
||||||
updateBackgroundTask(taskId, {
|
updateBackgroundTask(taskId, {
|
||||||
detail: '正在统计群消息类型',
|
detail: '正在统计群消息类型',
|
||||||
progressText: '消息类型'
|
progressText: '消息类型'
|
||||||
@@ -633,6 +688,12 @@ function GroupAnalyticsPage() {
|
|||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
const date = new Date(timestamp * 1000)
|
||||||
|
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizeFileName = (name: string) => {
|
const sanitizeFileName = (name: string) => {
|
||||||
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
|
||||||
}
|
}
|
||||||
@@ -764,6 +825,16 @@ function GroupAnalyticsPage() {
|
|||||||
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
await loadFunctionData('memberMessages', selectedGroup, member.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleViewMemberAnalyticsFromModal = async (member: GroupMember) => {
|
||||||
|
if (!selectedGroup) return
|
||||||
|
setSelectedMember(null)
|
||||||
|
setSelectedFunction('memberAnalytics')
|
||||||
|
setSelectedMessageMemberUsername(member.username)
|
||||||
|
setMessageMemberSearchKeyword('')
|
||||||
|
setShowMessageMemberSelect(false)
|
||||||
|
await loadFunctionData('memberAnalytics', selectedGroup, member.username)
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenMemberExportModal = () => {
|
const handleOpenMemberExportModal = () => {
|
||||||
setShowMessageMemberSelect(false)
|
setShowMessageMemberSelect(false)
|
||||||
setShowFormatSelect(false)
|
setShowFormatSelect(false)
|
||||||
@@ -982,6 +1053,14 @@ function GroupAnalyticsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="member-modal-primary-btn"
|
className="member-modal-primary-btn"
|
||||||
|
onClick={() => void handleViewMemberAnalyticsFromModal(selectedMember)}
|
||||||
|
>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
<span>分析该成员聊天</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="member-modal-secondary-btn"
|
||||||
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
onClick={() => void handleViewMemberMessagesFromModal(selectedMember)}
|
||||||
>
|
>
|
||||||
<MessageSquare size={16} />
|
<MessageSquare size={16} />
|
||||||
@@ -1080,6 +1159,11 @@ function GroupAnalyticsPage() {
|
|||||||
<span>成员消息筛选与导出</span>
|
<span>成员消息筛选与导出</span>
|
||||||
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
<small>按成员查看群聊消息,并支持导出当前成员记录</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="function-card" onClick={() => handleFunctionSelect('memberAnalytics')}>
|
||||||
|
<PieChart size={32} />
|
||||||
|
<span>群成员详细分析</span>
|
||||||
|
<small>针对群聊内某一用户的群聊记录进行详细分析,如发信数量、活跃周期等</small>
|
||||||
|
</div>
|
||||||
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
|
||||||
<BarChart3 size={32} />
|
<BarChart3 size={32} />
|
||||||
<span>群聊发言排行</span>
|
<span>群聊发言排行</span>
|
||||||
@@ -1104,6 +1188,7 @@ function GroupAnalyticsPage() {
|
|||||||
switch (selectedFunction) {
|
switch (selectedFunction) {
|
||||||
case 'members': return '群成员查看'
|
case 'members': return '群成员查看'
|
||||||
case 'memberMessages': return '成员消息筛选与导出'
|
case 'memberMessages': return '成员消息筛选与导出'
|
||||||
|
case 'memberAnalytics': return '群成员详细分析'
|
||||||
case 'ranking': return '群聊发言排行'
|
case 'ranking': return '群聊发言排行'
|
||||||
case 'activeHours': return '群聊活跃时段'
|
case 'activeHours': return '群聊活跃时段'
|
||||||
case 'mediaStats': return '媒体内容统计'
|
case 'mediaStats': return '媒体内容统计'
|
||||||
@@ -1282,6 +1367,187 @@ function GroupAnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{selectedFunction === 'memberAnalytics' && (
|
||||||
|
<div className="member-analytics-panel">
|
||||||
|
{members.length === 0 ? (
|
||||||
|
<div className="member-message-empty">暂无群成员数据,请先刷新。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="member-message-toolbar" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="member-export-field" ref={messageMemberSelectDropdownRef}>
|
||||||
|
<span>分析成员</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`select-trigger member-message-select-trigger ${showMessageMemberSelect ? 'open' : ''}`}
|
||||||
|
onClick={() => setShowMessageMemberSelect(prev => !prev)}
|
||||||
|
>
|
||||||
|
<div className="member-select-trigger-value">
|
||||||
|
<Avatar
|
||||||
|
src={selectedMessageMember?.avatarUrl}
|
||||||
|
name={selectedMessageMember?.displayName || selectedMessageMember?.username || '?'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<span className="select-value">{selectedMessageMember?.displayName || selectedMessageMember?.username || '请选择成员'}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
{showMessageMemberSelect && (
|
||||||
|
<div className="select-dropdown member-select-dropdown">
|
||||||
|
<div className="member-select-search">
|
||||||
|
<Search size={14} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={messageMemberSearchKeyword}
|
||||||
|
onChange={e => setMessageMemberSearchKeyword(e.target.value)}
|
||||||
|
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="member-select-options">
|
||||||
|
{filteredMessageMemberOptions.length === 0 ? (
|
||||||
|
<div className="member-select-empty">无匹配成员</div>
|
||||||
|
) : (
|
||||||
|
filteredMessageMemberOptions.map(member => (
|
||||||
|
<button
|
||||||
|
key={member.username}
|
||||||
|
type="button"
|
||||||
|
className={`select-option member-select-option ${selectedMessageMemberUsername === member.username ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMessageMemberUsername(member.username)
|
||||||
|
setShowMessageMemberSelect(false)
|
||||||
|
if (selectedGroup) {
|
||||||
|
void loadFunctionData('memberAnalytics', selectedGroup, member.username)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
|
||||||
|
<span className="member-option-main">{member.displayName || member.username}</span>
|
||||||
|
<span className="member-option-meta">
|
||||||
|
wxid: {member.username}
|
||||||
|
{member.alias ? ` · 微信号: ${member.alias}` : ''}
|
||||||
|
{member.remark ? ` · 备注: ${member.remark}` : ''}
|
||||||
|
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
|
||||||
|
{member.groupNickname ? ` · 群昵称: ${member.groupNickname}` : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{analyticsError ? (
|
||||||
|
<div className="member-message-empty">{analyticsError}</div>
|
||||||
|
) : memberAnalyticsData ? (
|
||||||
|
<div className="analytics-content-scrollable" style={{ padding: '0', display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<div className="stats-overview">
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"><MessageSquare size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{formatNumber(memberAnalyticsData.statistics.sentMessages)}</span>
|
||||||
|
<span className="stat-label">发信数量</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="stat-icon"><Clock size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{memberAnalyticsData.statistics.activeDays}</span>
|
||||||
|
<span className="stat-label">活跃天数</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
|
||||||
|
<div className="stat-icon"><Calendar size={24} /></div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">
|
||||||
|
{formatDate(memberAnalyticsData.statistics.firstMessageTime)} - {formatDate(memberAnalyticsData.statistics.lastMessageTime)}
|
||||||
|
</span>
|
||||||
|
<span className="stat-label">活跃周期</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="charts-grid">
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<h3>活跃时段</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ReactECharts
|
||||||
|
option={{
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
xAxis: { type: 'category', data: Array.from({ length: 24 }, (_, i) => `${i}时`) },
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series: [{ type: 'bar', data: Array.from({ length: 24 }, (_, i) => memberAnalyticsData.timeDistribution[i] || 0), itemStyle: { color: '#07c160', borderRadius: [4, 4, 0, 0] } }]
|
||||||
|
}}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-card wide">
|
||||||
|
<h3>消息类型分布</h3>
|
||||||
|
<div className="chart-wrapper">
|
||||||
|
<ReactECharts
|
||||||
|
option={{
|
||||||
|
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||||||
|
series: [{
|
||||||
|
type: 'pie',
|
||||||
|
radius: ['40%', '70%'],
|
||||||
|
data: [
|
||||||
|
{ name: '文本', value: memberAnalyticsData.statistics.textMessages, itemStyle: { color: '#3b82f6' } },
|
||||||
|
{ name: '图片', value: memberAnalyticsData.statistics.imageMessages, itemStyle: { color: '#22c55e' } },
|
||||||
|
{ name: '语音', value: memberAnalyticsData.statistics.voiceMessages, itemStyle: { color: '#f97316' } },
|
||||||
|
{ name: '视频', value: memberAnalyticsData.statistics.videoMessages, itemStyle: { color: '#a855f7' } },
|
||||||
|
{ name: '表情', value: memberAnalyticsData.statistics.emojiMessages, itemStyle: { color: '#ec4899' } },
|
||||||
|
{ name: '其他', value: memberAnalyticsData.statistics.otherMessages, itemStyle: { color: '#6b7280' } }
|
||||||
|
].filter(item => item.value > 0),
|
||||||
|
label: { show: true, formatter: '{b} {d}%' }
|
||||||
|
}]
|
||||||
|
}}
|
||||||
|
style={{ height: '300px', width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="chart-card wide" style={{ display: 'flex', gap: '32px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Hash size={18} /> 常用语</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{memberAnalyticsData.commonPhrases && memberAnalyticsData.commonPhrases.length > 0 ? (
|
||||||
|
memberAnalyticsData.commonPhrases.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{item.phrase}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}次</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>暂无常用语数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<h3 style={{ display: 'flex', alignItems: 'center', gap: '6px' }}><Smile size={18} /> 常用表情</h3>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{memberAnalyticsData.commonEmojis && memberAnalyticsData.commonEmojis.length > 0 ? (
|
||||||
|
memberAnalyticsData.commonEmojis.map((item: any, idx: number) => (
|
||||||
|
<div key={idx} style={{ background: 'var(--bg-tertiary)', padding: '6px 12px', borderRadius: '8px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px', border: '1px solid var(--border-color)' }}>
|
||||||
|
<span style={{ color: 'var(--text-primary)' }}>{item.emoji}</span>
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '11px' }}>{item.count}次</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '13px' }}>暂无表情包数据</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="content-loading"><Loader2 size={32} className="spin" /></div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{selectedFunction === 'ranking' && (
|
{selectedFunction === 'ranking' && (
|
||||||
<div className="rankings-list">
|
<div className="rankings-list">
|
||||||
{rankings.map((item, index) => (
|
{rankings.map((item, index) => (
|
||||||
|
|||||||
@@ -1401,6 +1401,220 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 版本更新页面
|
||||||
|
.updates-tab {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 18%, var(--border-color));
|
||||||
|
background:
|
||||||
|
linear-gradient(110deg, color-mix(in srgb, var(--bg-primary) 98%, #ffffff 2%), color-mix(in srgb, var(--bg-primary) 97%, var(--primary) 3%));
|
||||||
|
box-shadow: 0 8px 22px color-mix(in srgb, var(--primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero-main {
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-card {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 96%, var(--bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 34%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 95%, var(--primary) 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-card-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-card {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 60%, var(--border-color));
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-channel-card .desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-track {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 72%, #ffffff));
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.26), transparent);
|
||||||
|
animation: updatesShimmer 1.7s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-ignore-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.updates-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.updates-hero-action {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes updatesShimmer {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 关于页面
|
// 关于页面
|
||||||
.about-tab {
|
.about-tab {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
import './SettingsPage.scss'
|
||||||
|
|
||||||
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'security' | 'about' | 'analytics'
|
type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'cache' | 'api' | 'updates' | 'security' | 'about' | 'analytics'
|
||||||
|
|
||||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
@@ -24,14 +24,15 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
{ id: 'models', label: '模型管理', icon: Mic },
|
{ id: 'models', label: '模型管理', icon: Mic },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||||
|
|
||||||
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
||||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||||
|
{ id: 'updates', label: '版本更新', icon: RefreshCw },
|
||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
|
|
||||||
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
const isMac = navigator.userAgent.toLowerCase().includes('mac')
|
||||||
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
const isLinux = navigator.userAgent.toLowerCase().includes('linux')
|
||||||
|
const isWindows = !isMac && !isLinux
|
||||||
|
|
||||||
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
const dbDirName = isMac ? '2.0b4.0.9 目录' : 'xwechat_files 目录'
|
||||||
const dbPathPlaceholder = isMac
|
const dbPathPlaceholder = isMac
|
||||||
@@ -67,7 +68,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setDownloadProgress,
|
setDownloadProgress,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
setUpdateError
|
|
||||||
} = useAppStore()
|
} = useAppStore()
|
||||||
|
|
||||||
const resetChatStore = useChatStore((state) => state.reset)
|
const resetChatStore = useChatStore((state) => state.reset)
|
||||||
@@ -102,6 +102,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
|
||||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||||
|
|
||||||
|
const [httpApiToken, setHttpApiToken] = useState('')
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -110,6 +112,25 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateRandomToken = async () => {
|
||||||
|
// 生成 32 字符的十六进制随机字符串 (16 bytes)
|
||||||
|
const array = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(array)
|
||||||
|
const token = Array.from(array).map(b => b.toString(16).padStart(2, '0')).join('')
|
||||||
|
|
||||||
|
setHttpApiToken(token)
|
||||||
|
await configService.setHttpApiToken(token)
|
||||||
|
showMessage('已生成并保存新的 Access Token', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearApiToken = async () => {
|
||||||
|
setHttpApiToken('')
|
||||||
|
await configService.setHttpApiToken('')
|
||||||
|
showMessage('已清除 Access Token,API 将允许无鉴权访问', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||||
|
|
||||||
@@ -119,6 +140,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
|
||||||
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
const [windowCloseBehavior, setWindowCloseBehavior] = useState<configService.WindowCloseBehavior>('ask')
|
||||||
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
const [quoteLayout, setQuoteLayout] = useState<configService.QuoteLayout>('quote-top')
|
||||||
|
const [updateChannel, setUpdateChannel] = useState<configService.UpdateChannel>('stable')
|
||||||
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
|
||||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
@@ -168,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
// HTTP API 设置 state
|
// HTTP API 设置 state
|
||||||
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
const [httpApiEnabled, setHttpApiEnabled] = useState(false)
|
||||||
const [httpApiPort, setHttpApiPort] = useState(5031)
|
const [httpApiPort, setHttpApiPort] = useState(5031)
|
||||||
|
const [httpApiHost, setHttpApiHost] = useState('127.0.0.1')
|
||||||
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
const [httpApiRunning, setHttpApiRunning] = useState(false)
|
||||||
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
|
||||||
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
const [isTogglingApi, setIsTogglingApi] = useState(false)
|
||||||
@@ -191,11 +214,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
checkWaylandStatus()
|
checkWaylandStatus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 检查 Hello 可用性
|
// 检查 Hello 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.PublicKeyCredential) {
|
setHelloAvailable(isWindows)
|
||||||
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 检查 HTTP API 服务状态
|
// 检查 HTTP API 服务状态
|
||||||
@@ -316,10 +339,21 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
const savedMessagePushEnabled = await configService.getMessagePushEnabled()
|
||||||
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
const savedWindowCloseBehavior = await configService.getWindowCloseBehavior()
|
||||||
const savedQuoteLayout = await configService.getQuoteLayout()
|
const savedQuoteLayout = await configService.getQuoteLayout()
|
||||||
|
const savedUpdateChannel = await configService.getUpdateChannel()
|
||||||
|
|
||||||
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
|
||||||
const savedAuthUseHello = await configService.getAuthUseHello()
|
const savedAuthUseHello = await configService.getAuthUseHello()
|
||||||
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
|
||||||
|
|
||||||
|
const savedHttpApiToken = await configService.getHttpApiToken()
|
||||||
|
if (savedHttpApiToken) setHttpApiToken(savedHttpApiToken)
|
||||||
|
|
||||||
|
const savedApiPort = await configService.getHttpApiPort()
|
||||||
|
if (savedApiPort) setHttpApiPort(savedApiPort)
|
||||||
|
|
||||||
|
const savedApiHost = await configService.getHttpApiHost()
|
||||||
|
if (savedApiHost) setHttpApiHost(savedApiHost)
|
||||||
|
|
||||||
setAuthEnabled(savedAuthEnabled)
|
setAuthEnabled(savedAuthEnabled)
|
||||||
setAuthUseHello(savedAuthUseHello)
|
setAuthUseHello(savedAuthUseHello)
|
||||||
setIsLockMode(savedIsLockMode)
|
setIsLockMode(savedIsLockMode)
|
||||||
@@ -354,6 +388,18 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setMessagePushEnabled(savedMessagePushEnabled)
|
setMessagePushEnabled(savedMessagePushEnabled)
|
||||||
setWindowCloseBehavior(savedWindowCloseBehavior)
|
setWindowCloseBehavior(savedWindowCloseBehavior)
|
||||||
setQuoteLayout(savedQuoteLayout)
|
setQuoteLayout(savedQuoteLayout)
|
||||||
|
if (savedUpdateChannel) {
|
||||||
|
setUpdateChannel(savedUpdateChannel)
|
||||||
|
} else {
|
||||||
|
const currentVersion = await window.electronAPI.app.getVersion()
|
||||||
|
if (/^0\.\d{2}\.\d+$/i.test(currentVersion) || /-preview\.\d+\.\d+$/i.test(currentVersion)) {
|
||||||
|
setUpdateChannel('preview')
|
||||||
|
} else if (/^\d{2}\.\d{1,2}\.\d{1,2}$/i.test(currentVersion) || /-dev\.\d+\.\d+\.\d+$/i.test(currentVersion) || /(alpha|beta|rc)/i.test(currentVersion)) {
|
||||||
|
setUpdateChannel('dev')
|
||||||
|
} else {
|
||||||
|
setUpdateChannel('stable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||||
setWordCloudExcludeWords(savedExcludeWords)
|
setWordCloudExcludeWords(savedExcludeWords)
|
||||||
@@ -362,6 +408,8 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
const savedAnalyticsConsent = await configService.getAnalyticsConsent()
|
||||||
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
setAnalyticsConsent(savedAnalyticsConsent ?? false)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
const defaultLanguages = ['zh']
|
const defaultLanguages = ['zh']
|
||||||
@@ -477,7 +525,22 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleUpdateChannelChange = async (channel: configService.UpdateChannel) => {
|
||||||
|
if (channel === updateChannel) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUpdateChannel(channel)
|
||||||
|
await configService.setUpdateChannel(channel)
|
||||||
|
await configService.setIgnoredUpdateVersion('')
|
||||||
|
setUpdateInfo(null)
|
||||||
|
setShowUpdateDialog(false)
|
||||||
|
const channelLabel = channel === 'stable' ? '稳定版' : channel === 'preview' ? '预览版' : '开发版'
|
||||||
|
showMessage(`已切换到${channelLabel}更新渠道,正在检查更新`, true)
|
||||||
|
await handleCheckUpdate()
|
||||||
|
} catch (e: any) {
|
||||||
|
showMessage(`切换更新渠道失败: ${e}`, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showMessage = (text: string, success: boolean) => {
|
const showMessage = (text: string, success: boolean) => {
|
||||||
setMessage({ text, success })
|
setMessage({ text, success })
|
||||||
@@ -1439,6 +1502,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
|
|
||||||
const renderDatabaseTab = () => (
|
const renderDatabaseTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>连接测试</label>
|
||||||
|
<span className="form-hint">检测当前数据库配置是否可用</span>
|
||||||
|
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
||||||
|
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider" />
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>解密密钥</label>
|
<label>解密密钥</label>
|
||||||
<span className="form-hint">64位十六进制密钥</span>
|
<span className="form-hint">64位十六进制密钥</span>
|
||||||
@@ -1826,6 +1899,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
try {
|
try {
|
||||||
await window.electronAPI.http.stop()
|
await window.electronAPI.http.stop()
|
||||||
setHttpApiRunning(false)
|
setHttpApiRunning(false)
|
||||||
|
await configService.setHttpApiEnabled(false)
|
||||||
showMessage('API 服务已停止', true)
|
showMessage('API 服务已停止', true)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showMessage(`操作失败: ${e}`, false)
|
showMessage(`操作失败: ${e}`, false)
|
||||||
@@ -1839,10 +1913,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
setShowApiWarning(false)
|
setShowApiWarning(false)
|
||||||
setIsTogglingApi(true)
|
setIsTogglingApi(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.http.start(httpApiPort)
|
const result = await window.electronAPI.http.start(httpApiPort, httpApiHost)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setHttpApiRunning(true)
|
setHttpApiRunning(true)
|
||||||
if (result.port) setHttpApiPort(result.port)
|
if (result.port) setHttpApiPort(result.port)
|
||||||
|
|
||||||
|
await configService.setHttpApiEnabled(true)
|
||||||
|
await configService.setHttpApiPort(result.port || httpApiPort)
|
||||||
|
|
||||||
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
showMessage(`API 服务已启动,端口 ${result.port}`, true)
|
||||||
} else {
|
} else {
|
||||||
showMessage(`启动失败: ${result.error}`, false)
|
showMessage(`启动失败: ${result.error}`, false)
|
||||||
@@ -1855,7 +1933,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyApiUrl = () => {
|
const handleCopyApiUrl = () => {
|
||||||
const url = `http://127.0.0.1:${httpApiPort}`
|
const url = `http://${httpApiHost}:${httpApiPort}`
|
||||||
navigator.clipboard.writeText(url)
|
navigator.clipboard.writeText(url)
|
||||||
showMessage('已复制 API 地址', true)
|
showMessage('已复制 API 地址', true)
|
||||||
}
|
}
|
||||||
@@ -1887,6 +1965,26 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>监听地址</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
API 服务绑定的主机地址。默认 <code>127.0.0.1</code> 仅本机访问;Docker/N8N 等容器场景请改为 <code>0.0.0.0</code> 以允许外部访问(注意配合 Token 鉴权)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiHost}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
const host = e.target.value.trim() || '127.0.0.1'
|
||||||
|
setHttpApiHost(host)
|
||||||
|
scheduleConfigSave('httpApiHost', () => configService.setHttpApiHost(host))
|
||||||
|
}}
|
||||||
|
disabled={httpApiRunning}
|
||||||
|
style={{ width: 180, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>服务端口</label>
|
<label>服务端口</label>
|
||||||
<span className="form-hint">API 服务监听的端口号(1024-65535)</span>
|
<span className="form-hint">API 服务监听的端口号(1024-65535)</span>
|
||||||
@@ -1894,7 +1992,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
type="number"
|
type="number"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={httpApiPort}
|
value={httpApiPort}
|
||||||
onChange={(e) => setHttpApiPort(parseInt(e.target.value, 10) || 5031)}
|
onChange={(e) => {
|
||||||
|
const port = parseInt(e.target.value, 10) || 5031
|
||||||
|
setHttpApiPort(port)
|
||||||
|
scheduleConfigSave('httpApiPort', () => configService.setHttpApiPort(port))
|
||||||
|
}}
|
||||||
disabled={httpApiRunning}
|
disabled={httpApiRunning}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
min={1024}
|
min={1024}
|
||||||
@@ -1902,6 +2004,36 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Access Token (鉴权凭证)</label>
|
||||||
|
<span className="form-hint">
|
||||||
|
设置后,请求头需携带 <code>Authorization: Bearer <token></code>,
|
||||||
|
或者参数中携带 <code>?access_token=<token></code>
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="field-input"
|
||||||
|
value={httpApiToken}
|
||||||
|
placeholder="留空表示不验证 Token"
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
setHttpApiToken(val)
|
||||||
|
scheduleConfigSave('httpApiToken', () => configService.setHttpApiToken(val))
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-secondary" onClick={generateRandomToken}>
|
||||||
|
<RefreshCw size={14} style={{ marginRight: 4 }} /> 随机生成
|
||||||
|
</button>
|
||||||
|
{httpApiToken && (
|
||||||
|
<button className="btn btn-danger" onClick={clearApiToken} title="清除 Token">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{httpApiRunning && (
|
{httpApiRunning && (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>API 地址</label>
|
<label>API 地址</label>
|
||||||
@@ -1910,7 +2042,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={`http://127.0.0.1:${httpApiPort}`}
|
value={`http://${httpApiHost}:${httpApiPort}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
|
<button className="btn btn-secondary" onClick={handleCopyApiUrl} title="复制">
|
||||||
@@ -1959,13 +2091,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="field-input"
|
className="field-input"
|
||||||
value={`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}
|
value={`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`}
|
||||||
readOnly
|
readOnly
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`)
|
navigator.clipboard.writeText(`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages${httpApiToken ? `?access_token=${httpApiToken}` : ''}`)
|
||||||
showMessage('已复制推送地址', true)
|
showMessage('已复制推送地址', true)
|
||||||
}}
|
}}
|
||||||
title="复制"
|
title="复制"
|
||||||
@@ -1982,7 +2114,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<div className="api-item">
|
<div className="api-item">
|
||||||
<div className="api-endpoint">
|
<div className="api-endpoint">
|
||||||
<span className="method get">GET</span>
|
<span className="method get">GET</span>
|
||||||
<code>{`http://127.0.0.1:${httpApiPort}/api/v1/push/messages`}</code>
|
<code>{`http://${httpApiHost}:${httpApiPort}/api/v1/push/messages`}</code>
|
||||||
</div>
|
</div>
|
||||||
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
<p className="api-desc">通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。</p>
|
||||||
<div className="api-params">
|
<div className="api-params">
|
||||||
@@ -2039,33 +2171,29 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
showMessage('请输入当前密码以开启 Hello', false)
|
showMessage('请输入当前密码以开启 Hello', false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!isWindows) {
|
||||||
|
showMessage('当前系统不支持 Windows Hello', false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsSettingHello(true)
|
setIsSettingHello(true)
|
||||||
try {
|
try {
|
||||||
const challenge = new Uint8Array(32)
|
const verifyResult = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
|
||||||
window.crypto.getRandomValues(challenge)
|
if (!verifyResult.success) {
|
||||||
|
showMessage(verifyResult.error || 'Windows Hello 验证失败', false)
|
||||||
const credential = await navigator.credentials.create({
|
return
|
||||||
publicKey: {
|
}
|
||||||
challenge,
|
|
||||||
rp: { name: 'WeFlow', id: 'localhost' },
|
const saveResult = await window.electronAPI.auth.setHelloSecret(helloPassword)
|
||||||
user: { id: new Uint8Array([1]), name: 'user', displayName: 'User' },
|
if (!saveResult.success) {
|
||||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
showMessage('Windows Hello 配置保存失败', false)
|
||||||
authenticatorSelection: { userVerification: 'required' },
|
return
|
||||||
timeout: 60000
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
// 存储密码作为 Hello Secret,以便 Hello 解锁时能派生密钥
|
|
||||||
await window.electronAPI.auth.setHelloSecret(helloPassword)
|
|
||||||
setAuthUseHello(true)
|
setAuthUseHello(true)
|
||||||
setHelloPassword('')
|
setHelloPassword('')
|
||||||
showMessage('Windows Hello 设置成功', true)
|
showMessage('Windows Hello 设置成功', true)
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name !== 'NotAllowedError') {
|
showMessage(`Windows Hello 设置失败: ${e?.message || String(e)}`, false)
|
||||||
showMessage(`Windows Hello 设置失败: ${e.message}`, false)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSettingHello(false)
|
setIsSettingHello(false)
|
||||||
}
|
}
|
||||||
@@ -2310,35 +2438,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<img src="./logo.png" alt="WeFlow" />
|
<img src="./logo.png" alt="WeFlow" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="about-name">WeFlow</h2>
|
<h2 className="about-name">WeFlow</h2>
|
||||||
<p className="about-slogan">WeFlow</p>
|
|
||||||
<p className="about-version">v{appVersion || '...'}</p>
|
<p className="about-version">v{appVersion || '...'}</p>
|
||||||
|
|
||||||
<div className="about-update">
|
|
||||||
{updateInfo?.hasUpdate ? (
|
|
||||||
<>
|
|
||||||
<p className="update-hint">新版 v{updateInfo.version} 可用</p>
|
|
||||||
{isDownloading ? (
|
|
||||||
<div className="update-progress">
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div className="progress-inner" style={{ width: `${(downloadProgress?.percent || 0)}%` }} />
|
|
||||||
</div>
|
|
||||||
<span>{(downloadProgress?.percent || 0).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
|
|
||||||
<Download size={16} /> 立即更新
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
||||||
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
|
||||||
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
|
||||||
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="about-footer">
|
<div className="about-footer">
|
||||||
@@ -2350,7 +2450,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
<a href="#" onClick={(e) => { e.preventDefault(); window.electronAPI.window.openAgreementWindow() }}>用户协议</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="copyright">© 2025 WeFlow. All rights reserved.</p>
|
<p className="copyright">© 2026 WeFlow. All rights reserved.</p>
|
||||||
|
|
||||||
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
|
<div className="log-toggle-line" style={{ marginTop: '16px', justifyContent: 'center' }}>
|
||||||
<span style={{ fontSize: '13px', opacity: 0.7 }}>匿名数据收集</span>
|
<span style={{ fontSize: '13px', opacity: 0.7 }}>匿名数据收集</span>
|
||||||
@@ -2373,6 +2473,82 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const renderUpdatesTab = () => {
|
||||||
|
const downloadPercent = Math.max(0, Math.min(100, Number(downloadProgress?.percent || 0)))
|
||||||
|
const channelCards: { id: configService.UpdateChannel; title: string; desc: string }[] = [
|
||||||
|
{ id: 'stable', title: '稳定版', desc: '正式发布的版本,适合日常使用' },
|
||||||
|
{ id: 'preview', title: '预览版', desc: '正式发布前的预览体验版本' },
|
||||||
|
{ id: 'dev', title: '开发版', desc: '即刻体验我们的屎山代码' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tab-content updates-tab">
|
||||||
|
<div className="updates-hero">
|
||||||
|
<div className="updates-hero-main">
|
||||||
|
<span className="updates-chip">当前版本</span>
|
||||||
|
<h2>{appVersion || '...'}</h2>
|
||||||
|
<p>{updateInfo?.hasUpdate ? `发现新版本 v${updateInfo.version}` : '当前已是最新版本,可手动检查更新'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="updates-hero-action">
|
||||||
|
{updateInfo?.hasUpdate ? (
|
||||||
|
<button className="btn btn-primary" onClick={() => setShowUpdateDialog(true)}>
|
||||||
|
<Download size={16} /> 立即更新
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-secondary" onClick={handleCheckUpdate} disabled={isCheckingUpdate}>
|
||||||
|
<RefreshCw size={16} className={isCheckingUpdate ? 'spin' : ''} />
|
||||||
|
{isCheckingUpdate ? '检查中...' : '检查更新'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isDownloading || updateInfo?.hasUpdate) && (
|
||||||
|
<div className="updates-progress-card">
|
||||||
|
<div className="updates-progress-header">
|
||||||
|
<h3>{isDownloading ? `正在下载 v${updateInfo?.version || ''}` : `新版本 v${updateInfo?.version} 已就绪`}</h3>
|
||||||
|
{isDownloading ? <strong>{downloadPercent.toFixed(0)}%</strong> : <span>可立即安装</span>}
|
||||||
|
</div>
|
||||||
|
<div className="updates-progress-track">
|
||||||
|
<div className="updates-progress-fill" style={{ width: `${isDownloading ? downloadPercent : 100}%` }} />
|
||||||
|
</div>
|
||||||
|
{updateInfo?.hasUpdate && !isDownloading && (
|
||||||
|
<button className="btn btn-secondary updates-ignore-btn" onClick={handleIgnoreUpdate}>
|
||||||
|
暂不提醒此版本
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="updates-card">
|
||||||
|
<div className="updates-card-header">
|
||||||
|
<h3>更新渠道</h3>
|
||||||
|
<span>切换渠道后会自动重新检查</span>
|
||||||
|
</div>
|
||||||
|
<div className="update-channel-grid">
|
||||||
|
{channelCards.map((channel) => {
|
||||||
|
const active = updateChannel === channel.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={channel.id}
|
||||||
|
className={`update-channel-card ${active ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleUpdateChannelChange(channel.id)}
|
||||||
|
disabled={active}
|
||||||
|
>
|
||||||
|
<div className="update-channel-title-row">
|
||||||
|
<span className="title">{channel.title}</span>
|
||||||
|
{active && <Check size={16} />}
|
||||||
|
</div>
|
||||||
|
<span className="desc">{channel.desc}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
<div className={`settings-modal-overlay ${isClosing ? 'closing' : ''}`} onClick={handleClose}>
|
||||||
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
<div className={`settings-page ${isClosing ? 'closing' : ''}`} onClick={(event) => event.stopPropagation()}>
|
||||||
@@ -2420,9 +2596,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
<h1>设置</h1>
|
<h1>设置</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-actions">
|
<div className="settings-actions">
|
||||||
<button className="btn btn-secondary" onClick={handleTestConnection} disabled={isLoading || isTesting}>
|
|
||||||
<Plug size={16} /> {isTesting ? '测试中...' : '测试连接'}
|
|
||||||
</button>
|
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
<button type="button" className="settings-close-btn" onClick={handleClose} aria-label="关闭设置">
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
@@ -2452,6 +2625,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
|
|||||||
{activeTab === 'models' && renderModelsTab()}
|
{activeTab === 'models' && renderModelsTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'api' && renderApiTab()}
|
{activeTab === 'api' && renderApiTab()}
|
||||||
|
{activeTab === 'updates' && renderUpdatesTab()}
|
||||||
{activeTab === 'analytics' && renderAnalyticsTab()}
|
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||||
{activeTab === 'security' && renderSecurityTab()}
|
{activeTab === 'security' && renderSecurityTab()}
|
||||||
{activeTab === 'about' && renderAboutTab()}
|
{activeTab === 'about' && renderAboutTab()}
|
||||||
|
|||||||
@@ -103,9 +103,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
// 检查 Hello 可用性
|
// 检查 Hello 可用性
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.PublicKeyCredential) {
|
setHelloAvailable(isWindows)
|
||||||
void PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(setHelloAvailable)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function sha256(message: string) {
|
async function sha256(message: string) {
|
||||||
@@ -117,35 +115,27 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSetupHello = async () => {
|
const handleSetupHello = async () => {
|
||||||
|
if (!isWindows) {
|
||||||
|
setError('当前系统不支持 Windows Hello')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!authPassword || authPassword !== authConfirmPassword) {
|
||||||
|
setError('请先设置并确认应用密码,再开启 Windows Hello')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSettingHello(true)
|
setIsSettingHello(true)
|
||||||
try {
|
try {
|
||||||
// 注册凭证 (WebAuthn)
|
const result = await window.electronAPI.auth.hello('请验证您的身份以开启 Windows Hello')
|
||||||
const challenge = new Uint8Array(32)
|
if (!result.success) {
|
||||||
window.crypto.getRandomValues(challenge)
|
setError(`Windows Hello 设置失败: ${result.error || '验证失败'}`)
|
||||||
|
return
|
||||||
const credential = await navigator.credentials.create({
|
|
||||||
publicKey: {
|
|
||||||
challenge,
|
|
||||||
rp: { name: 'WeFlow', id: 'localhost' },
|
|
||||||
user: {
|
|
||||||
id: new Uint8Array([1]),
|
|
||||||
name: 'user',
|
|
||||||
displayName: 'User'
|
|
||||||
},
|
|
||||||
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
|
|
||||||
authenticatorSelection: { userVerification: 'required' },
|
|
||||||
timeout: 60000
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
setEnableHello(true)
|
setEnableHello(true)
|
||||||
// 成功提示?
|
setError('')
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name !== 'NotAllowedError') {
|
setError(`Windows Hello 设置失败: ${e?.message || String(e)}`)
|
||||||
setError('Windows Hello 设置失败: ' + e.message)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSettingHello(false)
|
setIsSettingHello(false)
|
||||||
}
|
}
|
||||||
@@ -502,7 +492,17 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
const hash = await sha256(authPassword)
|
const hash = await sha256(authPassword)
|
||||||
await configService.setAuthEnabled(true)
|
await configService.setAuthEnabled(true)
|
||||||
await configService.setAuthPassword(hash)
|
await configService.setAuthPassword(hash)
|
||||||
await configService.setAuthUseHello(enableHello)
|
if (enableHello) {
|
||||||
|
const helloResult = await window.electronAPI.auth.setHelloSecret(authPassword)
|
||||||
|
if (!helloResult.success) {
|
||||||
|
setError('Windows Hello 配置保存失败')
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.auth.clearHelloSecret()
|
||||||
|
await configService.setAuthUseHello(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await configService.setOnboardingDone(true)
|
await configService.setOnboardingDone(true)
|
||||||
|
|||||||
@@ -58,12 +58,17 @@ export const CONFIG_KEYS = {
|
|||||||
|
|
||||||
// 更新
|
// 更新
|
||||||
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
|
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
|
||||||
|
UPDATE_CHANNEL: 'updateChannel',
|
||||||
|
|
||||||
// 通知
|
// 通知
|
||||||
NOTIFICATION_ENABLED: 'notificationEnabled',
|
NOTIFICATION_ENABLED: 'notificationEnabled',
|
||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
HTTP_API_TOKEN: 'httpApiToken',
|
||||||
|
HTTP_API_ENABLED: 'httpApiEnabled',
|
||||||
|
HTTP_API_PORT: 'httpApiPort',
|
||||||
|
HTTP_API_HOST: 'httpApiHost',
|
||||||
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
MESSAGE_PUSH_ENABLED: 'messagePushEnabled',
|
||||||
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior',
|
||||||
QUOTE_LAYOUT: 'quoteLayout',
|
QUOTE_LAYOUT: 'quoteLayout',
|
||||||
@@ -92,6 +97,7 @@ export interface ExportDefaultMediaConfig {
|
|||||||
|
|
||||||
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'
|
||||||
|
|
||||||
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
const DEFAULT_EXPORT_MEDIA_CONFIG: ExportDefaultMediaConfig = {
|
||||||
images: true,
|
images: true,
|
||||||
@@ -117,6 +123,17 @@ export async function getDbPath(): Promise<string | null> {
|
|||||||
return value as string | null
|
return value as string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取api access_token
|
||||||
|
export async function getHttpApiToken(): Promise<string> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_TOKEN)
|
||||||
|
return (value as string) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置access_token
|
||||||
|
export async function setHttpApiToken(token: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_TOKEN, token)
|
||||||
|
}
|
||||||
|
|
||||||
// 设置数据库路径
|
// 设置数据库路径
|
||||||
export async function setDbPath(path: string): Promise<void> {
|
export async function setDbPath(path: string): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.DB_PATH, path)
|
await config.set(CONFIG_KEYS.DB_PATH, path)
|
||||||
@@ -663,6 +680,9 @@ export interface ContactsListCacheContact {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
labels?: string[]
|
||||||
|
detailDescription?: string
|
||||||
|
region?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1137,16 +1157,18 @@ export async function setSnsPageCache(
|
|||||||
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
||||||
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
|
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) {
|
||||||
return Math.floor(value)
|
const normalized = Math.floor(value)
|
||||||
|
// 兼容历史默认值 3000ms:自动提升到新的更稳妥阈值。
|
||||||
|
return normalized === 3000 ? 10000 : normalized
|
||||||
}
|
}
|
||||||
return 3000
|
return 10000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置通讯录加载超时阈值(毫秒)
|
// 设置通讯录加载超时阈值(毫秒)
|
||||||
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
|
export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise<void> {
|
||||||
const normalized = Number.isFinite(timeoutMs)
|
const normalized = Number.isFinite(timeoutMs)
|
||||||
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
|
? Math.min(60000, Math.max(1000, Math.floor(timeoutMs)))
|
||||||
: 3000
|
: 10000
|
||||||
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
|
await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1176,6 +1198,11 @@ export async function getContactsListCache(scopeKey: string): Promise<ContactsLi
|
|||||||
remark: typeof item.remark === 'string' ? item.remark : undefined,
|
remark: typeof item.remark === 'string' ? item.remark : undefined,
|
||||||
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
|
nickname: typeof item.nickname === 'string' ? item.nickname : undefined,
|
||||||
alias: typeof item.alias === 'string' ? item.alias : undefined,
|
alias: typeof item.alias === 'string' ? item.alias : undefined,
|
||||||
|
labels: Array.isArray(item.labels)
|
||||||
|
? Array.from(new Set(item.labels.map((label) => String(label || '').trim()).filter(Boolean)))
|
||||||
|
: undefined,
|
||||||
|
detailDescription: typeof item.detailDescription === 'string' ? (item.detailDescription.trim() || undefined) : undefined,
|
||||||
|
region: typeof item.region === 'string' ? (item.region.trim() || undefined) : undefined,
|
||||||
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other')
|
||||||
? type
|
? type
|
||||||
: 'other'
|
: 'other'
|
||||||
@@ -1210,6 +1237,11 @@ export async function setContactsListCache(scopeKey: string, contacts: ContactsL
|
|||||||
remark: contact?.remark ? String(contact.remark) : undefined,
|
remark: contact?.remark ? String(contact.remark) : undefined,
|
||||||
nickname: contact?.nickname ? String(contact.nickname) : undefined,
|
nickname: contact?.nickname ? String(contact.nickname) : undefined,
|
||||||
alias: contact?.alias ? String(contact.alias) : undefined,
|
alias: contact?.alias ? String(contact.alias) : undefined,
|
||||||
|
labels: Array.isArray(contact?.labels)
|
||||||
|
? Array.from(new Set(contact.labels.map((label) => String(label || '').trim()).filter(Boolean)))
|
||||||
|
: undefined,
|
||||||
|
detailDescription: contact?.detailDescription ? (String(contact.detailDescription).trim() || undefined) : undefined,
|
||||||
|
region: contact?.region ? (String(contact.region).trim() || undefined) : undefined,
|
||||||
type
|
type
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1351,6 +1383,18 @@ export async function setIgnoredUpdateVersion(version: string): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取更新渠道(空值/auto 视为未显式设置,交由安装包类型决定默认渠道)
|
||||||
|
export async function getUpdateChannel(): Promise<UpdateChannel | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.UPDATE_CHANNEL)
|
||||||
|
if (value === 'stable' || value === 'preview' || value === 'dev') return value
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置更新渠道
|
||||||
|
export async function setUpdateChannel(channel: UpdateChannel): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.UPDATE_CHANNEL, channel)
|
||||||
|
}
|
||||||
|
|
||||||
// 获取通知开关
|
// 获取通知开关
|
||||||
export async function getNotificationEnabled(): Promise<boolean> {
|
export async function getNotificationEnabled(): Promise<boolean> {
|
||||||
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
|
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
|
||||||
@@ -1457,3 +1501,35 @@ export async function getAnalyticsDenyCount(): Promise<number> {
|
|||||||
export async function setAnalyticsDenyCount(count: number): Promise<void> {
|
export async function setAnalyticsDenyCount(count: number): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
|
await config.set(CONFIG_KEYS.ANALYTICS_DENY_COUNT, count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 获取 HTTP API 自动启动状态
|
||||||
|
export async function getHttpApiEnabled(): Promise<boolean> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_ENABLED)
|
||||||
|
return value === true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 HTTP API 自动启动状态
|
||||||
|
export async function setHttpApiEnabled(enabled: boolean): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_ENABLED, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 HTTP API 端口
|
||||||
|
export async function getHttpApiPort(): Promise<number> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_PORT)
|
||||||
|
return typeof value === 'number' ? value : 5031
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 HTTP API 端口
|
||||||
|
export async function setHttpApiPort(port: number): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_PORT, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHttpApiHost(): Promise<string> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.HTTP_API_HOST)
|
||||||
|
return typeof value === 'string' && value.trim() ? value.trim() : '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setHttpApiHost(host: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.HTTP_API_HOST, host)
|
||||||
|
}
|
||||||
|
|||||||
31
src/types/electron.d.ts
vendored
31
src/types/electron.d.ts
vendored
@@ -219,7 +219,7 @@ export interface ElectronAPI {
|
|||||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||||
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
deleteMessage: (sessionId: string, localId: number, createTime: number, dbPathHint?: string) => Promise<{ success: boolean; error?: string }>
|
||||||
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
resolveTransferDisplayNames: (chatroomId: string, payerUsername: string, receiverUsername: string) => Promise<{ payerName: string; receiverName: string }>
|
||||||
getContacts: () => Promise<{
|
getContacts: (options?: { lite?: boolean }) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
contacts?: ContactInfo[]
|
contacts?: ContactInfo[]
|
||||||
error?: string
|
error?: string
|
||||||
@@ -326,6 +326,11 @@ export interface ElectronAPI {
|
|||||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
biz: {
|
||||||
|
listAccounts: (account?: string) => Promise<any[]>
|
||||||
|
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||||
|
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||||
|
}
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||||
@@ -496,6 +501,28 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getGroupMemberAnalytics: (chatroomId: string, memberUsername: string, startTime?: number, endTime?: number) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
statistics: {
|
||||||
|
totalMessages: number
|
||||||
|
textMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
otherMessages: number
|
||||||
|
sentMessages: number
|
||||||
|
receivedMessages: number
|
||||||
|
firstMessageTime: number | null
|
||||||
|
lastMessageTime: number | null
|
||||||
|
activeDays: number
|
||||||
|
messageTypeCounts: Record<number, number>
|
||||||
|
}
|
||||||
|
timeDistribution: Record<number, number>
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getGroupMemberMessages: (
|
getGroupMemberMessages: (
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
memberUsername: string,
|
memberUsername: string,
|
||||||
@@ -838,7 +865,7 @@ export interface ElectronAPI {
|
|||||||
getLogs: () => Promise<string[]>
|
getLogs: () => Promise<string[]>
|
||||||
}
|
}
|
||||||
http: {
|
http: {
|
||||||
start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
|
start: (port?: number, host?: string) => Promise<{ success: boolean; port?: number; error?: string }>
|
||||||
stop: () => Promise<{ success: boolean }>
|
stop: () => Promise<{ success: boolean }>
|
||||||
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
|
status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export interface ContactInfo {
|
|||||||
remark?: string
|
remark?: string
|
||||||
nickname?: string
|
nickname?: string
|
||||||
alias?: string
|
alias?: string
|
||||||
|
labels?: string[]
|
||||||
|
detailDescription?: string
|
||||||
|
region?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
temp_assets.json
Normal file
BIN
temp_assets.json
Normal file
Binary file not shown.
@@ -17,8 +17,7 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
}
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
|||||||
strictPort: false // 如果3000被占用,自动尝试下一个
|
strictPort: false // 如果3000被占用,自动尝试下一个
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
chunkSizeWarningLimit: 900,
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
ignoreDynamicRequires: true
|
ignoreDynamicRequires: true
|
||||||
}
|
}
|
||||||
@@ -171,6 +172,7 @@ export default defineConfig({
|
|||||||
renderer()
|
renderer()
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
alias: {
|
alias: {
|
||||||
'@': resolve(__dirname, 'src')
|
'@': resolve(__dirname, 'src')
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user