From f2d6188c534cd81b973c0d3e91519ef681da8f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=98=E5=BF=97=E9=AB=98?= <874747760@qq.com> Date: Sat, 11 Apr 2026 22:00:32 +0800 Subject: [PATCH 01/12] =?UTF-8?q?feat(export):=20=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E8=8C=83=E5=9B=B4=E6=B7=BB=E5=8A=A0=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E9=80=89=E6=8B=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为导出窗口的日期范围选择器添加了时间(HH:mm)选择功能: - 在日期输入框下方添加了时间选择控件(type="time") - 默认时间范围:开始 00:00,结束 23:59 - 支持精确到分钟的时间范围设置 - 预设类型(今天、昨天、最近7天等)默认使用 00:00-23:59 - 自定义时间范围保留用户设置的具体时间 - 添加了结束时间不能早于开始时间的验证 修改文件: - src/utils/exportDateRange.ts - 支持 YYYY-MM-DD HH:mm 格式的解析和格式化 - src/components/Export/ExportDateRangeDialog.tsx - 添加时间选择 UI 和逻辑 - src/components/Export/ExportDateRangeDialog.scss - 时间输入框样式 - src/pages/ExportPage.tsx - 修复 preset 类型的默认时间不正确的 bug --- .../Export/ExportDateRangeDialog.scss | 26 ++ .../Export/ExportDateRangeDialog.tsx | 264 ++++++++++++++---- src/pages/ExportPage.tsx | 45 ++- src/utils/exportDateRange.ts | 17 +- 4 files changed, 279 insertions(+), 73 deletions(-) diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index 3907662..db06241 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -192,6 +192,32 @@ } } +.export-date-range-time-input { + width: 100%; + min-width: 0; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + height: 30px; + padding: 0 9px; + font-size: 12px; + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + } + + &::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.6; + &:hover { + opacity: 1; + } + } +} + .export-date-range-calendar-nav { display: inline-flex; align-items: center; diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index 8a49fdd..346b44c 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -57,16 +57,42 @@ const clampSelectionToBounds = ( const bounds = resolveBounds(minDate, maxDate) if (!bounds) return cloneExportDateRangeSelection(value) - const rawStart = value.useAllTime ? bounds.minDate : startOfDay(value.dateRange.start) - const rawEnd = value.useAllTime ? bounds.maxDate : endOfDay(value.dateRange.end) - const nextStart = new Date(Math.min(Math.max(rawStart.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) - const nextEndCandidate = new Date(Math.min(Math.max(rawEnd.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) - const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate - const changed = nextStart.getTime() !== rawStart.getTime() || nextEnd.getTime() !== rawEnd.getTime() + // For custom selections, only ensure end >= start, preserve time precision + if (value.preset === 'custom' && !value.useAllTime) { + const { start, end } = value.dateRange + if (end.getTime() < start.getTime()) { + return { + ...value, + dateRange: { start, end: start } + } + } + return cloneExportDateRangeSelection(value) + } + + // For useAllTime, use bounds directly + if (value.useAllTime) { + return { + preset: value.preset, + useAllTime: true, + dateRange: { + start: bounds.minDate, + end: bounds.maxDate + } + } + } + + // For preset selections (not custom), clamp dates to bounds and use default times + const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate + + // Set default times: start at 00:00:00, end at 23:59:59 + nextStart.setHours(0, 0, 0, 0) + nextEnd.setHours(23, 59, 59, 999) return { - preset: value.useAllTime ? value.preset : (changed ? 'custom' : value.preset), - useAllTime: value.useAllTime, + preset: value.preset, + useAllTime: false, dateRange: { start: nextStart, end: nextEnd @@ -95,62 +121,98 @@ export function ExportDateRangeDialog({ onClose, onConfirm }: ExportDateRangeDialogProps) { + // Helper: Format date only (YYYY-MM-DD) for the date input field + const formatDateOnly = (date: Date): string => { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` + } + + // Helper: Format time only (HH:mm) for the time input field + const formatTimeOnly = (date: Date): string => { + const h = `${date.getHours()}`.padStart(2, '0') + const m = `${date.getMinutes()}`.padStart(2, '0') + return `${h}:${m}` + } + const [draft, setDraft] = useState(() => buildDialogDraft(value, minDate, maxDate)) const [activeBoundary, setActiveBoundary] = useState('start') const [dateInput, setDateInput] = useState({ - start: formatDateInputValue(value.dateRange.start), - end: formatDateInputValue(value.dateRange.end) + start: formatDateOnly(value.dateRange.start), + end: formatDateOnly(value.dateRange.end) }) const [dateInputError, setDateInputError] = useState({ start: false, end: false }) + // Default times: start at 00:00, end at 23:59 + const [timeInput, setTimeInput] = useState({ + start: '00:00', + end: '23:59' + }) + useEffect(() => { if (!open) return const nextDraft = buildDialogDraft(value, minDate, maxDate) setDraft(nextDraft) setActiveBoundary('start') setDateInput({ - start: formatDateInputValue(nextDraft.dateRange.start), - end: formatDateInputValue(nextDraft.dateRange.end) + start: formatDateOnly(nextDraft.dateRange.start), + end: formatDateOnly(nextDraft.dateRange.end) }) + // For preset-based selections (not custom), use default times 00:00 and 23:59 + // For custom selections, preserve the time from value.dateRange + if (nextDraft.useAllTime || nextDraft.preset !== 'custom') { + setTimeInput({ + start: '00:00', + end: '23:59' + }) + } else { + setTimeInput({ + start: formatTimeOnly(nextDraft.dateRange.start), + end: formatTimeOnly(nextDraft.dateRange.end) + }) + } setDateInputError({ start: false, end: false }) }, [maxDate, minDate, open, value]) useEffect(() => { if (!open) return setDateInput({ - start: formatDateInputValue(draft.dateRange.start), - end: formatDateInputValue(draft.dateRange.end) + start: formatDateOnly(draft.dateRange.start), + end: formatDateOnly(draft.dateRange.end) }) + // Don't sync timeInput here - it's controlled by the time picker setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) const clampStartDate = useCallback((targetDate: Date) => { - const start = startOfDay(targetDate) - if (!bounds) return start - if (start.getTime() < bounds.minDate.getTime()) return bounds.minDate - if (start.getTime() > bounds.maxDate.getTime()) return startOfDay(bounds.maxDate) - return start + if (!bounds) return targetDate + const min = bounds.minDate + const max = bounds.maxDate + if (targetDate.getTime() < min.getTime()) return min + if (targetDate.getTime() > max.getTime()) return max + return targetDate }, [bounds]) const clampEndDate = useCallback((targetDate: Date) => { - const end = endOfDay(targetDate) - if (!bounds) return end - if (end.getTime() < bounds.minDate.getTime()) return endOfDay(bounds.minDate) - if (end.getTime() > bounds.maxDate.getTime()) return bounds.maxDate - return end + if (!bounds) return targetDate + const min = bounds.minDate + const max = bounds.maxDate + if (targetDate.getTime() < min.getTime()) return min + if (targetDate.getTime() > max.getTime()) return max + return targetDate }, [bounds]) const setRangeStart = useCallback((targetDate: Date) => { const start = clampStartDate(targetDate) setDraft(prev => { - const nextEnd = prev.dateRange.end < start ? endOfDay(start) : prev.dateRange.end return { ...prev, preset: 'custom', useAllTime: false, dateRange: { start, - end: nextEnd + end: prev.dateRange.end }, panelMonth: toMonthStart(start) } @@ -161,14 +223,13 @@ export function ExportDateRangeDialog({ const end = clampEndDate(targetDate) setDraft(prev => { const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start - const nextEnd = end < nextStart ? endOfDay(nextStart) : end return { ...prev, preset: 'custom', useAllTime: false, dateRange: { start: nextStart, - end: nextEnd + end: end }, panelMonth: toMonthStart(targetDate) } @@ -206,25 +267,74 @@ export function ExportDateRangeDialog({ setActiveBoundary('start') }, [bounds, maxDate, minDate]) + const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => { + const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim()) + if (!matched) return null + const hours = Number(matched[1]) + const minutes = Number(matched[2]) + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null + return { hours, minutes } + } + + // Handle time picker changes - update draft.dateRange immediately + const handleTimePickerChange = useCallback((boundary: 'start' | 'end', timeStr: string) => { + setTimeInput(prev => ({ ...prev, [boundary]: timeStr })) + + const parsedTime = parseTimeValue(timeStr) + if (!parsedTime) return + + setDraft(prev => { + const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end + const newDate = new Date(dateObj) + newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) + return { + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + ...prev.dateRange, + [boundary]: newDate + } + } + }) + }, []) + + // Check if date input string contains time (YYYY-MM-DD HH:mm format) + const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim()) + const commitStartFromInput = useCallback(() => { - const parsed = parseDateInputValue(dateInput.start) - if (!parsed) { + const parsedDate = parseDateInputValue(dateInput.start) + if (!parsedDate) { setDateInputError(prev => ({ ...prev, start: true })) return } + // Only apply time picker value if date input doesn't contain time + if (!dateInputHasTime(dateInput.start)) { + const parsedTime = parseTimeValue(timeInput.start) + if (parsedTime) { + parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) + } + } setDateInputError(prev => ({ ...prev, start: false })) - setRangeStart(parsed) - }, [dateInput.start, setRangeStart]) + setRangeStart(parsedDate) + }, [dateInput.start, timeInput.start, setRangeStart]) const commitEndFromInput = useCallback(() => { - const parsed = parseDateInputValue(dateInput.end) - if (!parsed) { + const parsedDate = parseDateInputValue(dateInput.end) + if (!parsedDate) { setDateInputError(prev => ({ ...prev, end: true })) return } + // Only apply time picker value if date input doesn't contain time + if (!dateInputHasTime(dateInput.end)) { + const parsedTime = parseTimeValue(timeInput.end) + if (parsedTime) { + parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0) + } + } setDateInputError(prev => ({ ...prev, end: false })) - setRangeEnd(parsed) - }, [dateInput.end, setRangeEnd]) + setRangeEnd(parsedDate) + }, [dateInput.end, timeInput.end, setRangeEnd]) const shiftPanelMonth = useCallback((delta: number) => { setDraft(prev => ({ @@ -234,30 +344,47 @@ export function ExportDateRangeDialog({ }, []) const handleCalendarSelect = useCallback((targetDate: Date) => { + // Use time from timeInput state (which is updated by the time picker) + const parseTime = (timeStr: string): { hours: number; minutes: number } => { + const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim()) + if (!matched) return { hours: 0, minutes: 0 } + return { hours: Number(matched[1]), minutes: Number(matched[2]) } + } + if (activeBoundary === 'start') { - setRangeStart(targetDate) + const newStart = new Date(targetDate) + const time = parseTime(timeInput.start) + newStart.setHours(time.hours, time.minutes, 0, 0) + setRangeStart(newStart) setActiveBoundary('end') return } - setDraft(prev => { - const start = prev.useAllTime ? startOfDay(targetDate) : prev.dateRange.start - const pickedStart = startOfDay(targetDate) - const nextStart = pickedStart <= start ? pickedStart : start - const nextEnd = pickedStart <= start ? endOfDay(start) : endOfDay(targetDate) - return { - ...prev, - preset: 'custom', - useAllTime: false, - dateRange: { - start: nextStart, - end: nextEnd - }, - panelMonth: toMonthStart(targetDate) - } - }) + const pickedStart = startOfDay(targetDate) + const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start + const nextStart = pickedStart <= start ? pickedStart : start + + const newEnd = new Date(targetDate) + const time = parseTime(timeInput.end) + // If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput + if (pickedStart <= start) { + newEnd.setHours(23, 59, 59, 999) + } else { + newEnd.setHours(time.hours, time.minutes, 59, 999) + } + + setDraft(prev => ({ + ...prev, + preset: 'custom', + useAllTime: false, + dateRange: { + start: nextStart, + end: newEnd + }, + panelMonth: toMonthStart(targetDate) + })) setActiveBoundary('start') - }, [activeBoundary, setRangeEnd, setRangeStart]) + }, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart]) const isRangeModeActive = !draft.useAllTime const modeText = isRangeModeActive @@ -364,6 +491,16 @@ export function ExportDateRangeDialog({ }} onBlur={commitStartFromInput} /> + { + handleTimePickerChange('start', event.target.value) + }} + onFocus={() => setActiveBoundary('start')} + onClick={(event) => event.stopPropagation()} + />
+ { + handleTimePickerChange('end', event.target.value) + }} + onFocus={() => setActiveBoundary('end')} + onClick={(event) => event.stopPropagation()} + />
@@ -453,7 +600,14 @@ export function ExportDateRangeDialog({ diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1f95d36..20390a4 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1105,21 +1105,42 @@ const clampExportSelectionToBounds = ( ): ExportDateRangeSelection => { if (!bounds) return cloneExportDateRangeSelection(selection) - const boundedStart = startOfDay(bounds.minDate) - const boundedEnd = endOfDay(bounds.maxDate) - const originalStart = selection.useAllTime ? boundedStart : startOfDay(selection.dateRange.start) - const originalEnd = selection.useAllTime ? boundedEnd : endOfDay(selection.dateRange.end) - const nextStart = new Date(Math.min(Math.max(originalStart.getTime(), boundedStart.getTime()), boundedEnd.getTime())) - const nextEndCandidate = new Date(Math.min(Math.max(originalEnd.getTime(), boundedStart.getTime()), boundedEnd.getTime())) - const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? endOfDay(nextStart) : nextEndCandidate - const rangeChanged = nextStart.getTime() !== originalStart.getTime() || nextEnd.getTime() !== originalEnd.getTime() + // For custom selections, only ensure end >= start, preserve time precision + if (selection.preset === 'custom' && !selection.useAllTime) { + const { start, end } = selection.dateRange + if (end.getTime() < start.getTime()) { + return { + ...selection, + dateRange: { start, end: start } + } + } + return cloneExportDateRangeSelection(selection) + } + // For useAllTime, use bounds directly + if (selection.useAllTime) { + return { + preset: selection.preset, + useAllTime: true, + dateRange: { + start: bounds.minDate, + end: bounds.maxDate + } + } + } + + // For preset selections (not custom), clamp dates to bounds and use default times + const boundedStart = new Date(Math.min(Math.max(selection.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + const boundedEnd = new Date(Math.min(Math.max(selection.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime())) + // Use default times: start at 00:00, end at 23:59:59 + boundedStart.setHours(0, 0, 0, 0) + boundedEnd.setHours(23, 59, 59, 999) return { - preset: selection.useAllTime ? selection.preset : (rangeChanged ? 'custom' : selection.preset), - useAllTime: selection.useAllTime, + preset: selection.preset, + useAllTime: false, dateRange: { - start: nextStart, - end: nextEnd + start: boundedStart, + end: boundedEnd } } } diff --git a/src/utils/exportDateRange.ts b/src/utils/exportDateRange.ts index e1f2def..8c019e8 100644 --- a/src/utils/exportDateRange.ts +++ b/src/utils/exportDateRange.ts @@ -138,19 +138,24 @@ export const formatDateInputValue = (date: Date): string => { const y = date.getFullYear() const m = `${date.getMonth() + 1}`.padStart(2, '0') const d = `${date.getDate()}`.padStart(2, '0') - return `${y}-${m}-${d}` + const h = `${date.getHours()}`.padStart(2, '0') + const min = `${date.getMinutes()}`.padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` } export const parseDateInputValue = (raw: string): Date | null => { const text = String(raw || '').trim() - const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text) + const matched = /^(\d{4})-(\d{2})-(\d{2})(?:\s+(\d{2}):(\d{2}))?$/.exec(text) if (!matched) return null const year = Number(matched[1]) const month = Number(matched[2]) const day = Number(matched[3]) + const hour = matched[4] !== undefined ? Number(matched[4]) : 0 + const minute = matched[5] !== undefined ? Number(matched[5]) : 0 if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null if (month < 1 || month > 12 || day < 1 || day > 31) return null - const parsed = new Date(year, month - 1, day) + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null + const parsed = new Date(year, month - 1, day, hour, minute, 0, 0) if ( parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || @@ -291,14 +296,14 @@ export const resolveExportDateRangeConfig = ( const parsedStart = parseStoredDate(raw.start) const parsedEnd = parseStoredDate(raw.end) if (parsedStart && parsedEnd) { - const start = startOfDay(parsedStart) - const end = endOfDay(parsedEnd) + const start = parsedStart + const end = parsedEnd return { preset: 'custom', useAllTime: false, dateRange: { start, - end: end < start ? endOfDay(start) : end + end: end < start ? start : end } } } From caf5b0c9db5c8d8bb828623f591a59ef350900e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=98=E5=BF=97=E9=AB=98?= <874747760@qq.com> Date: Sat, 11 Apr 2026 22:17:34 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(export):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E8=BE=93=E5=85=A5=E6=A1=86=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E4=B8=8E=E6=97=A5=E6=9C=9F=E8=BE=93=E5=85=A5=E6=A1=86=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Export/ExportDateRangeDialog.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index db06241..d4c5f9c 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -202,6 +202,7 @@ height: 30px; padding: 0 9px; font-size: 12px; + font-family: inherit; &:focus { outline: none; From 0f3422295487eeb04216833541488be962a5f8dc Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Sat, 11 Apr 2026 22:53:38 +0800 Subject: [PATCH 03/12] chore: update xkey for linux --- resources/key/linux/x64/xkey_helper_linux | Bin 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 resources/key/linux/x64/xkey_helper_linux diff --git a/resources/key/linux/x64/xkey_helper_linux b/resources/key/linux/x64/xkey_helper_linux old mode 100644 new mode 100755 From 49ab0de7b350b77e1afac5ce9eb43810b550283c Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Sat, 11 Apr 2026 22:59:20 +0800 Subject: [PATCH 04/12] =?UTF-8?q?release=20action=20=E4=B8=BAlinux?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=B7=BB=E5=8A=A0=E5=8F=AF=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44cf1bb..c276bae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,11 @@ jobs: - name: Install Dependencies run: npm install + - name: Ensure linux key helper is executable + shell: bash + run: | + [ -f "resources/key/linux/x64/xkey_helper" ] && chmod +x "resources/key/linux/x64/xkey_helper" || echo "File not found" + - name: Sync version with tag shell: bash run: | From 567fcd36835d84f02c1276cf06e19821a5103564 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Sat, 11 Apr 2026 23:27:33 +0800 Subject: [PATCH 05/12] Auto update aur release --- .github/workflows/release.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c276bae..693ec7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -316,3 +316,22 @@ jobs: EOF gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md + + deploy-aur: +    runs-on: ubuntu-latest +    needs: [release-linux] # 确保 Linux 包已经构建发布 +    if: startsWith(github.ref, 'refs/tags/v') +    steps: +      - name: Checkout code +        uses: actions/checkout@v5 +        with: +          fetch-depth: 0 + +      - name: Publish AUR package +        uses: KSX_Zeus/github-action-aur@master +        with: + pkgname: weflow +          ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} +          commit_username: H3CoF6 +          commit_email: h3cof6@gmail.com +          ssh_keyscan_types: ed25519 From 933912f15d62b9b5d21c9cc995b1d3173c0db938 Mon Sep 17 00:00:00 2001 From: H3CoF6 <1707889225@qq.com> Date: Sun, 12 Apr 2026 00:50:52 +0800 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8Dyml=E7=A9=BA?= =?UTF-8?q?=E6=A0=BC=E7=9A=84bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 693ec7b..db35077 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -318,20 +318,20 @@ jobs: gh release edit "$TAG" --repo "$REPO" --notes-file release_notes.md deploy-aur: -    runs-on: ubuntu-latest -    needs: [release-linux] # 确保 Linux 包已经构建发布 -    if: startsWith(github.ref, 'refs/tags/v') -    steps: -      - name: Checkout code -        uses: actions/checkout@v5 -        with: -          fetch-depth: 0 + runs-on: ubuntu-latest + needs: [release-linux] # 确保 Linux 包已经构建发布 + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 -      - name: Publish AUR package -        uses: KSX_Zeus/github-action-aur@master -        with: + - name: Publish AUR package + uses: KSX_Zeus/github-action-aur@master + with: pkgname: weflow -          ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} -          commit_username: H3CoF6 -          commit_email: h3cof6@gmail.com -          ssh_keyscan_types: ed25519 + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_username: H3CoF6 + commit_email: h3cof6@gmail.com + ssh_keyscan_types: ed25519 From cde359098620c69d1931e1175bf24d4f65ece612 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 12 Apr 2026 07:10:59 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=80=E4=B8=8Bui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Export/ExportDateRangeDialog.scss | 130 ++++++++++++- .../Export/ExportDateRangeDialog.tsx | 182 +++++++++++++++--- 2 files changed, 282 insertions(+), 30 deletions(-) diff --git a/src/components/Export/ExportDateRangeDialog.scss b/src/components/Export/ExportDateRangeDialog.scss index d4c5f9c..215520e 100644 --- a/src/components/Export/ExportDateRangeDialog.scss +++ b/src/components/Export/ExportDateRangeDialog.scss @@ -192,7 +192,18 @@ } } -.export-date-range-time-input { +.export-date-range-time-select { + position: relative; + width: 100%; + + &.open .export-date-range-time-trigger { + border-color: var(--primary); + box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); + color: var(--primary); + } +} + +.export-date-range-time-trigger { width: 100%; min-width: 0; border-radius: 8px; @@ -203,20 +214,125 @@ padding: 0 9px; font-size: 12px; font-family: inherit; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease; &:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18); } +} - &::-webkit-calendar-picker-indicator { - cursor: pointer; - opacity: 0.6; - &:hover { - opacity: 1; - } +.export-date-range-time-trigger-value { + flex: 1; + min-width: 0; + text-align: left; +} + +.export-date-range-time-dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + z-index: 24; + border: 1px solid var(--border-color); + border-radius: 12px; + background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary)); + box-shadow: var(--shadow-md); + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); +} + +.export-date-range-time-dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + span { + font-size: 11px; + color: var(--text-secondary); } + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.export-date-range-time-quick-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.export-date-range-time-quick-item, +.export-date-range-time-option { + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + border-color: rgba(var(--primary-rgb), 0.28); + background: rgba(var(--primary-rgb), 0.12); + color: var(--primary); + } +} + +.export-date-range-time-quick-item { + min-width: 52px; + height: 28px; + padding: 0 10px; + font-size: 11px; +} + +.export-date-range-time-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.export-date-range-time-column { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.export-date-range-time-column-label { + font-size: 11px; + color: var(--text-secondary); +} + +.export-date-range-time-column-list { + max-height: 168px; + overflow-y: auto; + padding-right: 2px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; +} + +.export-date-range-time-option { + min-height: 28px; + padding: 0 8px; + font-size: 11px; } .export-date-range-calendar-nav { diff --git a/src/components/Export/ExportDateRangeDialog.tsx b/src/components/Export/ExportDateRangeDialog.tsx index 346b44c..d2cbabf 100644 --- a/src/components/Export/ExportDateRangeDialog.tsx +++ b/src/components/Export/ExportDateRangeDialog.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Check, ChevronLeft, ChevronRight, X } from 'lucide-react' +import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react' import { EXPORT_DATE_RANGE_PRESETS, WEEKDAY_SHORT_LABELS, @@ -10,7 +10,6 @@ import { createDateRangeByPreset, createDefaultDateRange, formatCalendarMonthTitle, - formatDateInputValue, isSameDay, parseDateInputValue, startOfDay, @@ -37,6 +36,10 @@ interface ExportDateRangeDialogDraft extends ExportDateRangeSelection { panelMonth: Date } +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0')) +const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0')) +const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59'] + const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => { if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null @@ -149,6 +152,9 @@ export function ExportDateRangeDialog({ start: '00:00', end: '23:59' }) + const [openTimeDropdown, setOpenTimeDropdown] = useState(null) + const startTimeSelectRef = useRef(null) + const endTimeSelectRef = useRef(null) useEffect(() => { if (!open) return @@ -172,6 +178,7 @@ export function ExportDateRangeDialog({ end: formatTimeOnly(nextDraft.dateRange.end) }) } + setOpenTimeDropdown(null) setDateInputError({ start: false, end: false }) }, [maxDate, minDate, open, value]) @@ -185,6 +192,33 @@ export function ExportDateRangeDialog({ setDateInputError({ start: false, end: false }) }, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open]) + useEffect(() => { + if (!openTimeDropdown) return + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target as Node + const activeContainer = openTimeDropdown === 'start' + ? startTimeSelectRef.current + : endTimeSelectRef.current + if (!activeContainer?.contains(target)) { + setOpenTimeDropdown(null) + } + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpenTimeDropdown(null) + } + } + + document.addEventListener('mousedown', handlePointerDown) + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('mousedown', handlePointerDown) + document.removeEventListener('keydown', handleEscape) + } + }, [openTimeDropdown]) + const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate]) const clampStartDate = useCallback((targetDate: Date) => { if (!bounds) return targetDate @@ -241,6 +275,11 @@ export function ExportDateRangeDialog({ const previewRange = bounds ? { start: bounds.minDate, end: bounds.maxDate } : createDefaultDateRange() + setTimeInput({ + start: '00:00', + end: '23:59' + }) + setOpenTimeDropdown(null) setDraft(prev => ({ ...prev, preset, @@ -257,6 +296,11 @@ export function ExportDateRangeDialog({ useAllTime: false, dateRange: createDateRangeByPreset(preset) }, minDate, maxDate).dateRange + setTimeInput({ + start: '00:00', + end: '23:59' + }) + setOpenTimeDropdown(null) setDraft(prev => ({ ...prev, preset, @@ -276,8 +320,7 @@ export function ExportDateRangeDialog({ return { hours, minutes } } - // Handle time picker changes - update draft.dateRange immediately - const handleTimePickerChange = useCallback((boundary: 'start' | 'end', timeStr: string) => { + const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => { setTimeInput(prev => ({ ...prev, [boundary]: timeStr })) const parsedTime = parseTimeValue(timeStr) @@ -299,6 +342,82 @@ export function ExportDateRangeDialog({ }) }, []) + const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => { + setActiveBoundary(boundary) + setOpenTimeDropdown(prev => (prev === boundary ? null : boundary)) + }, []) + + const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => { + const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? { + hours: boundary === 'start' ? 0 : 23, + minutes: boundary === 'start' ? 0 : 59 + } + const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours + const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes + updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`) + }, [timeInput, updateBoundaryTime]) + + const renderTimeDropdown = (boundary: ActiveBoundary) => { + const currentTime = timeInput[boundary] + const parsedCurrent = parseTimeValue(currentTime) ?? { + hours: boundary === 'start' ? 0 : 23, + minutes: boundary === 'start' ? 0 : 59 + } + + return ( +
event.stopPropagation()}> +
+ {boundary === 'start' ? '开始时间' : '结束时间'} + {currentTime} +
+
+ {QUICK_TIME_OPTIONS.map(option => ( + + ))} +
+
+
+ 小时 +
+ {HOUR_OPTIONS.map(option => ( + + ))} +
+
+
+ 分钟 +
+ {MINUTE_OPTIONS.map(option => ( + + ))} +
+
+
+
+ ) + } + // Check if date input string contains time (YYYY-MM-DD HH:mm format) const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim()) @@ -357,6 +476,7 @@ export function ExportDateRangeDialog({ newStart.setHours(time.hours, time.minutes, 0, 0) setRangeStart(newStart) setActiveBoundary('end') + setOpenTimeDropdown(null) return } @@ -369,6 +489,7 @@ export function ExportDateRangeDialog({ // If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput if (pickedStart <= start) { newEnd.setHours(23, 59, 59, 999) + setTimeInput(prev => ({ ...prev, end: '23:59' })) } else { newEnd.setHours(time.hours, time.minutes, 59, 999) } @@ -384,6 +505,7 @@ export function ExportDateRangeDialog({ panelMonth: toMonthStart(targetDate) })) setActiveBoundary('start') + setOpenTimeDropdown(null) }, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart]) const isRangeModeActive = !draft.useAllTime @@ -491,16 +613,23 @@ export function ExportDateRangeDialog({ }} onBlur={commitStartFromInput} /> - { - handleTimePickerChange('start', event.target.value) - }} - onFocus={() => setActiveBoundary('start')} +
event.stopPropagation()} - /> + > + + {openTimeDropdown === 'start' && renderTimeDropdown('start')} +
- { - handleTimePickerChange('end', event.target.value) - }} - onFocus={() => setActiveBoundary('end')} +
event.stopPropagation()} - /> + > + + {openTimeDropdown === 'end' && renderTimeDropdown('end')} +
From f2f78bb4e232615fda3f8d5e62d07434c4a7b29d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 12 Apr 2026 08:03:12 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E4=BA=86=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=8F=B7=E7=9A=84=E6=8E=A8=E9=80=81=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E6=9C=AA=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/bizService.ts | 13 +- electron/services/chatService.ts | 254 ++++++++++++++++++++++ electron/services/config.ts | 4 + electron/services/messagePushService.ts | 163 ++++++++++++-- src/pages/BizPage.scss | 19 ++ src/pages/BizPage.tsx | 62 ++++-- src/pages/ChatPage.tsx | 28 ++- src/pages/SettingsPage.scss | 58 +++++ src/pages/SettingsPage.tsx | 277 +++++++++++++++++++++++- src/services/config.ts | 25 +++ 10 files changed, 857 insertions(+), 46 deletions(-) diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts index f7c0eed..a5bb984 100644 --- a/electron/services/bizService.ts +++ b/electron/services/bizService.ts @@ -13,6 +13,7 @@ export interface BizAccount { type: number last_time: number formatted_last_time: string + unread_count?: number } export interface BizMessage { @@ -104,19 +105,24 @@ export class BizService { if (!root || !accountWxid) return [] const bizLatestTime: Record = {} + const bizUnreadCount: Record = {} try { - const sessionsRes = await wcdbService.getSessions() + const sessionsRes = await chatService.getSessions() if (sessionsRes.success && sessionsRes.sessions) { for (const session of sessionsRes.sessions) { const uname = session.username || session.strUsrName || session.userName || session.id // 适配日志中发现的字段,注意转为整型数字 - const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' + const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' const time = parseInt(timeStr.toString(), 10) if (usernames.includes(uname) && time > 0) { bizLatestTime[uname] = time } + if (usernames.includes(uname)) { + const unread = Number(session.unreadCount ?? session.unread_count ?? 0) + bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0 + } } } } catch (e) { @@ -152,7 +158,8 @@ export class BizService { avatar: info?.avatarUrl || '', type: 0, last_time: lastTime, - formatted_last_time: formatBizTime(lastTime) + formatted_last_time: formatBizTime(lastTime), + unread_count: bizUnreadCount[uname] || 0 } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 90f2555..9c06ed2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -232,6 +232,16 @@ interface SessionDetailExtra { type SessionDetail = SessionDetailFast & SessionDetailExtra +interface SyntheticUnreadState { + readTimestamp: number + scannedTimestamp: number + latestTimestamp: number + unreadCount: number + summaryTimestamp?: number + summary?: string + lastMsgType?: number +} + interface MyFootprintSummary { private_inbound_people: number private_replied_people: number @@ -378,6 +388,7 @@ class ChatService { private readonly messageDbCountSnapshotCacheTtlMs = 8000 private sessionMessageCountCache = new Map() private sessionMessageCountHintCache = new Map() + private syntheticUnreadState = new Map() private sessionMessageCountBatchCache: { dbSignature: string sessionIdsKey: string @@ -865,6 +876,10 @@ class ChatService { } } + await this.addMissingOfficialSessions(sessions, myWxid) + await this.applySyntheticUnreadCounts(sessions) + sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0)) + // 不等待联系人信息加载,直接返回基础会话列表 // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } @@ -874,6 +889,242 @@ class ChatService { } } + private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise { + const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean)) + try { + const contactResult = await wcdbService.getContactsCompact() + if (!contactResult.success || !Array.isArray(contactResult.contacts)) return + + for (const row of contactResult.contacts as Record[]) { + const username = String(row.username || '').trim() + if (!username.startsWith('gh_') || existing.has(username)) continue + + sessions.push({ + username, + type: 0, + unreadCount: 0, + summary: '查看公众号历史消息', + sortTimestamp: 0, + lastTimestamp: 0, + lastMsgType: 0, + displayName: row.remark || row.nick_name || row.alias || username, + avatarUrl: undefined, + selfWxid: myWxid + }) + existing.add(username) + } + } catch (error) { + console.warn('[ChatService] 补充公众号会话失败:', error) + } + } + + private shouldUseSyntheticUnread(sessionId: string): boolean { + const normalized = String(sessionId || '').trim() + return normalized.startsWith('gh_') + } + + private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> { + const tableStatsResult = await wcdbService.getMessageTableStats(sessionId) + if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) { + return { total: 0, latestTimestamp: 0 } + } + + let total = 0 + let latestTimestamp = 0 + for (const row of tableStatsResult.tables as Record[]) { + const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0) + if (Number.isFinite(count) && count > 0) { + total += Math.floor(count) + } + + const latest = Number( + row.last_timestamp ?? + row.lastTimestamp ?? + row.last_time ?? + row.lastTime ?? + row.max_create_time ?? + row.maxCreateTime ?? + 0 + ) + if (Number.isFinite(latest) && latest > latestTimestamp) { + latestTimestamp = Math.floor(latest) + } + } + + return { total, latestTimestamp } + } + + private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise { + const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username)) + if (candidates.length === 0) return + + for (const session of candidates) { + try { + const snapshot = await this.getSessionMessageStatsSnapshot(session.username) + const latestTimestamp = Math.max( + Number(session.lastTimestamp || 0), + Number(session.sortTimestamp || 0), + snapshot.latestTimestamp + ) + if (latestTimestamp > 0) { + session.lastTimestamp = latestTimestamp + session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp) + } + if (snapshot.total > 0) { + session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total) + this.sessionMessageCountHintCache.set(session.username, session.messageCountHint) + } + + let state = this.syntheticUnreadState.get(session.username) + if (!state) { + const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp) + state = { + readTimestamp: latestTimestamp, + scannedTimestamp: latestTimestamp, + latestTimestamp, + unreadCount: initialUnread.count + } + if (initialUnread.latestMessage) { + state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage) + state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp) + state.lastMsgType = Number(initialUnread.latestMessage.localType || 0) + } + this.syntheticUnreadState.set(session.username, state) + } + + let latestMessageForSummary: Message | undefined + if (latestTimestamp > state.scannedTimestamp) { + const newMessagesResult = await this.getNewMessages( + session.username, + Math.max(0, state.scannedTimestamp), + 1000 + ) + if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) { + let nextUnread = state.unreadCount + let nextScannedTimestamp = state.scannedTimestamp + for (const message of newMessagesResult.messages) { + const createTime = Number(message.createTime || 0) + if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue + if (message.isSend === 1) continue + nextUnread += 1 + latestMessageForSummary = message + if (createTime > nextScannedTimestamp) { + nextScannedTimestamp = Math.floor(createTime) + } + } + state.unreadCount = nextUnread + state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp) + } else { + state.scannedTimestamp = latestTimestamp + } + } + + state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp) + if (latestMessageForSummary) { + const summary = this.getSessionSummaryFromMessage(latestMessageForSummary) + if (summary) { + state.summary = summary + state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp) + state.lastMsgType = Number(latestMessageForSummary.localType || 0) + } + } + if (state.summary) { + session.summary = state.summary + session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0) + } + session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount) + } catch (error) { + console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error) + } + } + } + + private getSessionSummaryFromMessage(message: Message): string { + const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim() + let summary = '' + switch (Number(message.localType || 0)) { + case 1: + summary = message.parsedContent || message.rawContent || '' + break + case 3: + summary = '[图片]' + break + case 34: + summary = '[语音]' + break + case 43: + summary = '[视频]' + break + case 47: + summary = '[表情]' + break + case 42: + summary = message.cardNickname || '[名片]' + break + case 48: + summary = '[位置]' + break + case 49: + summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]' + break + default: + summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0)) + break + } + return cleanOfficialPrefix(this.cleanString(summary)) + } + + private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{ + count: number + latestMessage?: Message + }> { + const normalizedLatest = Number(latestTimestamp || 0) + if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 } + + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) { + return { count: 0 } + } + + const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20) + if (!result.success || !Array.isArray(result.messages)) return { count: 0 } + const unreadMessages = result.messages.filter((message) => { + const createTime = Number(message.createTime || 0) + return Number.isFinite(createTime) && + createTime >= normalizedLatest && + message.isSend !== 1 + }) + return { + count: unreadMessages.length, + latestMessage: unreadMessages[unreadMessages.length - 1] + } + } + + private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void { + const normalized = String(sessionId || '').trim() + if (!this.shouldUseSyntheticUnread(normalized)) return + + let latestTimestamp = 0 + const state = this.syntheticUnreadState.get(normalized) + if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp) + for (const message of messages) { + const createTime = Number(message.createTime || 0) + if (Number.isFinite(createTime) && createTime > latestTimestamp) { + latestTimestamp = Math.floor(createTime) + } + } + + this.syntheticUnreadState.set(normalized, { + readTimestamp: latestTimestamp, + scannedTimestamp: latestTimestamp, + latestTimestamp, + unreadCount: 0, + summary: state?.summary, + summaryTimestamp: state?.summaryTimestamp, + lastMsgType: state?.lastMsgType + }) + } + async getSessionStatuses(usernames: string[]): Promise<{ success: boolean map?: Record @@ -1814,6 +2065,9 @@ class ChatService { releaseMessageCursorMutex?.() this.messageCacheService.set(sessionId, filtered) + if (offset === 0 && startTime === 0 && endTime === 0) { + this.markSyntheticUnreadRead(sessionId, filtered) + } console.log( `[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}` ) diff --git a/electron/services/config.ts b/electron/services/config.ts index fb05832..250c93d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -61,6 +61,8 @@ interface ConfigSchema { notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] messagePushEnabled: boolean + messagePushFilterMode: 'all' | 'whitelist' | 'blacklist' + messagePushFilterList: string[] httpApiEnabled: boolean httpApiPort: number httpApiHost: string @@ -177,6 +179,8 @@ export class ConfigService { httpApiPort: 5031, httpApiHost: '127.0.0.1', messagePushEnabled: false, + messagePushFilterMode: 'all', + messagePushFilterList: [], windowCloseBehavior: 'ask', quoteLayout: 'quote-top', wordCloudExcludeWords: [], diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index 95c180c..ca7e057 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -11,6 +11,7 @@ interface SessionBaseline { interface MessagePushPayload { event: 'message.new' sessionId: string + sessionType: 'private' | 'group' | 'official' | 'other' messageKey: string avatarUrl?: string sourceName: string @@ -20,6 +21,8 @@ interface MessagePushPayload { const PUSH_CONFIG_KEYS = new Set([ 'messagePushEnabled', + 'messagePushFilterMode', + 'messagePushFilterList', 'dbPath', 'decryptKey', 'myWxid' @@ -38,6 +41,7 @@ class MessagePushService { private rerunRequested = false private started = false private baselineReady = false + private messageTableScanRequested = false constructor() { this.configService = ConfigService.getInstance() @@ -60,12 +64,15 @@ class MessagePushService { payload = null } - const tableName = String(payload?.table || '').trim().toLowerCase() - if (tableName && tableName !== 'session') { + const tableName = String(payload?.table || '').trim() + if (this.isSessionTableChange(tableName)) { + this.scheduleSync() return } - this.scheduleSync() + if (!tableName || this.isMessageTableChange(tableName)) { + this.scheduleSync({ scanMessageBackedSessions: true }) + } } async handleConfigChanged(key: string): Promise { @@ -91,6 +98,7 @@ class MessagePushService { this.recentMessageKeys.clear() this.groupNicknameCache.clear() this.baselineReady = false + this.messageTableScanRequested = false if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = null @@ -121,7 +129,11 @@ class MessagePushService { this.baselineReady = true } - private scheduleSync(): void { + private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void { + if (options.scanMessageBackedSessions) { + this.messageTableScanRequested = true + } + if (this.debounceTimer) { clearTimeout(this.debounceTimer) } @@ -141,6 +153,8 @@ class MessagePushService { this.processing = true try { if (!this.isPushEnabled()) return + const scanMessageBackedSessions = this.messageTableScanRequested + this.messageTableScanRequested = false const connectResult = await chatService.connect() if (!connectResult.success) { @@ -163,27 +177,47 @@ class MessagePushService { const previousBaseline = new Map(this.sessionBaseline) this.setBaseline(sessions) - const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session)) + const candidates = sessions.filter((session) => { + const previous = previousBaseline.get(session.username) + if (this.shouldInspectSession(previous, session)) { + return true + } + return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session) + }) for (const session of candidates) { - await this.pushSessionMessages(session, previousBaseline.get(session.username)) + await this.pushSessionMessages( + session, + previousBaseline.get(session.username) || this.sessionBaseline.get(session.username) + ) } } finally { this.processing = false if (this.rerunRequested) { this.rerunRequested = false - this.scheduleSync() + this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested }) } } } private setBaseline(sessions: ChatSession[]): void { + const previousBaseline = new Map(this.sessionBaseline) + const nextBaseline = new Map() + const nowSeconds = Math.floor(Date.now() / 1000) this.sessionBaseline.clear() for (const session of sessions) { - this.sessionBaseline.set(session.username, { - lastTimestamp: Number(session.lastTimestamp || 0), + const username = String(session.username || '').trim() + if (!username) continue + const previous = previousBaseline.get(username) + const sessionTimestamp = Number(session.lastTimestamp || 0) + const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds + nextBaseline.set(username, { + lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp), unreadCount: Number(session.unreadCount || 0) }) } + for (const [username, baseline] of nextBaseline.entries()) { + this.sessionBaseline.set(username, baseline) + } } private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { @@ -204,16 +238,30 @@ class MessagePushService { return unreadCount > 0 && lastTimestamp > 0 } - if (lastTimestamp <= previous.lastTimestamp) { + return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount + } + + private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { return false } - // unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送 - return unreadCount > previous.unreadCount + const summary = String(session.summary || '').trim() + if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) { + return false + } + + const sessionType = this.getSessionType(sessionId, session) + if (sessionType === 'private') { + return false + } + + return Boolean(previous) || Number(session.lastTimestamp || 0) > 0 } private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise { - const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1) + const since = Math.max(0, Number(previous?.lastTimestamp || 0)) const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) { return @@ -224,7 +272,7 @@ class MessagePushService { if (!messageKey) continue if (message.isSend === 1) continue - if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) { + if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) { continue } @@ -234,9 +282,11 @@ class MessagePushService { const payload = await this.buildPayload(session, message) if (!payload) continue + if (!this.shouldPushPayload(payload)) continue httpService.broadcastMessagePush(payload) this.rememberMessageKey(messageKey) + this.bumpSessionBaseline(session.username, message) } } @@ -246,6 +296,7 @@ class MessagePushService { if (!sessionId || !messageKey) return null const isGroup = sessionId.endsWith('@chatroom') + const sessionType = this.getSessionType(sessionId, session) const content = this.getMessageDisplayContent(message) if (isGroup) { @@ -255,6 +306,7 @@ class MessagePushService { return { event: 'message.new', sessionId, + sessionType, messageKey, avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, groupName, @@ -267,6 +319,7 @@ class MessagePushService { return { event: 'message.new', sessionId, + sessionType, messageKey, avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, @@ -274,10 +327,84 @@ class MessagePushService { } } + private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] { + if (sessionId.endsWith('@chatroom')) { + return 'group' + } + if (sessionId.startsWith('gh_') || session.type === 'official') { + return 'official' + } + if (session.type === 'friend') { + return 'private' + } + return 'other' + } + + private shouldPushPayload(payload: MessagePushPayload): boolean { + const sessionId = String(payload.sessionId || '').trim() + const filterMode = this.getMessagePushFilterMode() + if (filterMode === 'all') { + return true + } + + const filterList = this.getMessagePushFilterList() + const listed = filterList.has(sessionId) + if (filterMode === 'whitelist') { + return listed + } + return !listed + } + + private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' { + const value = this.configService.get('messagePushFilterMode') + if (value === 'whitelist' || value === 'blacklist') return value + return 'all' + } + + private getMessagePushFilterList(): Set { + const value = this.configService.get('messagePushFilterList') + if (!Array.isArray(value)) return new Set() + return new Set(value.map((item) => String(item || '').trim()).filter(Boolean)) + } + + private isSessionTableChange(tableName: string): boolean { + return String(tableName || '').trim().toLowerCase() === 'session' + } + + private isMessageTableChange(tableName: string): boolean { + const normalized = String(tableName || '').trim().toLowerCase() + if (!normalized) return false + return normalized === 'message' || + normalized === 'msg' || + normalized.startsWith('message_') || + normalized.startsWith('msg_') || + normalized.includes('message') + } + + private bumpSessionBaseline(sessionId: string, message: Message): void { + const key = String(sessionId || '').trim() + if (!key) return + + const createTime = Number(message.createTime || 0) + if (!Number.isFinite(createTime) || createTime <= 0) return + + const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 } + if (createTime > current.lastTimestamp) { + this.sessionBaseline.set(key, { + ...current, + lastTimestamp: createTime + }) + } + } + private getMessageDisplayContent(message: Message): string | null { + const cleanOfficialPrefix = (value: string | null): string | null => { + if (!value) return value + return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value + } switch (Number(message.localType || 0)) { case 1: - return message.rawContent || null + return cleanOfficialPrefix(message.rawContent || null) case 3: return '[图片]' case 34: @@ -287,13 +414,13 @@ class MessagePushService { case 47: return '[表情]' case 42: - return message.cardNickname || '[名片]' + return cleanOfficialPrefix(message.cardNickname || '[名片]') case 48: return '[位置]' case 49: - return message.linkTitle || message.fileName || '[消息]' + return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]') default: - return message.parsedContent || message.rawContent || null + return cleanOfficialPrefix(message.parsedContent || message.rawContent || null) } } diff --git a/src/pages/BizPage.scss b/src/pages/BizPage.scss index a2faddb..5ff28c6 100644 --- a/src/pages/BizPage.scss +++ b/src/pages/BizPage.scss @@ -11,6 +11,7 @@ } .biz-account-item { + position: relative; display: flex; align-items: center; gap: 12px; @@ -46,6 +47,24 @@ background-color: var(--bg-tertiary); } + .biz-unread-badge { + position: absolute; + top: 8px; + left: 52px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ff4d4f; + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + border: 2px solid var(--bg-secondary); + box-sizing: border-box; + } + .biz-info { flex: 1; min-width: 0; diff --git a/src/pages/BizPage.tsx b/src/pages/BizPage.tsx index 6831d54..be7b547 100644 --- a/src/pages/BizPage.tsx +++ b/src/pages/BizPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useThemeStore } from '../stores/themeStore'; import { Newspaper, MessageSquareOff } from 'lucide-react'; import './BizPage.scss'; @@ -10,6 +10,7 @@ export interface BizAccount { type: string; last_time: number; formatted_last_time: string; + unread_count?: number; } export const BizAccountList: React.FC<{ @@ -36,25 +37,42 @@ export const BizAccountList: React.FC<{ initWxid().then(_r => { }); }, []); - useEffect(() => { - const fetch = async () => { - if (!myWxid) { - return; - } + const fetchAccounts = useCallback(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 => { } ); + setLoading(true); + try { + const res = await window.electronAPI.biz.listAccounts(myWxid) + setAccounts(res || []); + } catch (err) { + console.error('获取服务号列表失败:', err); + } finally { + setLoading(false); + } }, [myWxid]); + useEffect(() => { + fetchAccounts().then(_r => { }); + }, [fetchAccounts]); + + useEffect(() => { + if (!window.electronAPI.chat.onWcdbChange) return; + const removeListener = window.electronAPI.chat.onWcdbChange((_event: any, data: { json?: string }) => { + try { + const payload = JSON.parse(data.json || '{}'); + const tableName = String(payload.table || '').toLowerCase(); + if (!tableName || tableName === 'session' || tableName.includes('message') || tableName.startsWith('msg_')) { + fetchAccounts().then(_r => { }); + } + } catch { + fetchAccounts().then(_r => { }); + } + }); + return () => removeListener(); + }, [fetchAccounts]); + const filtered = useMemo(() => { let result = accounts; @@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{ {filtered.map(item => (
onSelect(item)} + onClick={() => { + setAccounts(prev => prev.map(account => + account.username === item.username ? { ...account, unread_count: 0 } : account + )); + onSelect({ ...item, unread_count: 0 }); + }} className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`} > + {(item.unread_count || 0) > 0 && ( + {(item.unread_count || 0) > 99 ? '99+' : item.unread_count} + )}
{item.name || item.username} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index ec05e62..7c94600 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1058,6 +1058,13 @@ const SessionItem = React.memo(function SessionItem({
{session.summary || '查看公众号历史消息'} +
+ {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
@@ -5049,24 +5056,37 @@ function ChatPage(props: ChatPageProps) { return [] } + const officialSessions = sessions.filter(s => s.username.startsWith('gh_')) + // 检查是否有折叠的群聊 const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 let visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false + if (s.username.startsWith('gh_')) return false return true }) + const latestOfficial = officialSessions.reduce((latest, current) => { + if (!latest) return current + const latestTime = latest.sortTimestamp || latest.lastTimestamp + const currentTime = current.sortTimestamp || current.lastTimestamp + return currentTime > latestTime ? current : latest + }, null) + const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0) + const bizEntry: ChatSession = { username: OFFICIAL_ACCOUNTS_VIRTUAL_ID, displayName: '公众号', - summary: '查看公众号历史消息', + summary: latestOfficial + ? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}` + : '查看公众号历史消息', type: 0, sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下 - lastTimestamp: 0, - lastMsgType: 0, - unreadCount: 0, + lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0, + lastMsgType: latestOfficial?.lastMsgType || 0, + unreadCount: officialUnreadCount, isMuted: false, isFolded: false } diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 3761efc..ac35a22 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -2349,6 +2349,24 @@ border-radius: 10px; } +.filter-panel-action { + flex-shrink: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary)); + } +} + .filter-panel-list { flex: 1; min-height: 200px; @@ -2412,6 +2430,16 @@ white-space: nowrap; } + .filter-item-type { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 6px; + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + } + .filter-item-action { font-size: 18px; font-weight: 500; @@ -2421,6 +2449,36 @@ } } +.push-filter-type-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + margin-bottom: 10px; +} + +.push-filter-type-tab { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 6px 10px; + font-size: 13px; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + } +} + .filter-panel-empty { display: flex; align-items: center; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 3dfacf8..6b05c7a 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' +import type { ContactInfo } from '../types/models' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, @@ -225,6 +226,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) const [messagePushEnabled, setMessagePushEnabled] = useState(false) + const [messagePushFilterMode, setMessagePushFilterMode] = useState('all') + const [messagePushFilterList, setMessagePushFilterList] = useState([]) + const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false) + const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('') + const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<'all' | configService.MessagePushSessionType>('all') + const [messagePushContactOptions, setMessagePushContactOptions] = useState([]) const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState>(new Set()) const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState>({}) @@ -356,15 +363,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setFilterModeDropdownOpen(false) setPositionDropdownOpen(false) setCloseBehaviorDropdownOpen(false) + setMessagePushFilterDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -387,6 +395,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() + const savedMessagePushFilterMode = await configService.getMessagePushFilterMode() + const savedMessagePushFilterList = await configService.getMessagePushFilterList() + const contactsResult = await window.electronAPI.chat.getContacts({ lite: true }) const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() @@ -437,6 +448,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) + setMessagePushFilterMode(savedMessagePushFilterMode) + setMessagePushFilterList(savedMessagePushFilterList) + if (contactsResult.success && Array.isArray(contactsResult.contacts)) { + setMessagePushContactOptions(contactsResult.contacts as ContactInfo[]) + } setLaunchAtStartup(savedLaunchAtStartupStatus.enabled) setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported) setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '') @@ -2517,6 +2533,116 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) } + const getMessagePushSessionType = (session: { username: string; type?: ContactInfo['type'] | number }): configService.MessagePushSessionType => { + const username = String(session.username || '').trim() + if (username.endsWith('@chatroom')) return 'group' + if (username.startsWith('gh_') || session.type === 'official') return 'official' + if (username.toLowerCase().includes('placeholder_foldgroup')) return 'other' + if (session.type === 'former_friend' || session.type === 'other') return 'other' + return 'private' + } + + const getMessagePushTypeLabel = (type: configService.MessagePushSessionType) => { + switch (type) { + case 'private': return '私聊' + case 'group': return '群聊' + case 'official': return '订阅号/服务号' + default: return '其他/非好友' + } + } + + const handleSetMessagePushFilterMode = async (mode: configService.MessagePushFilterMode) => { + setMessagePushFilterMode(mode) + setMessagePushFilterDropdownOpen(false) + await configService.setMessagePushFilterMode(mode) + showMessage( + mode === 'all' ? '主动推送已设为接收所有会话' : + mode === 'whitelist' ? '主动推送已设为仅推送白名单' : '主动推送已设为屏蔽黑名单', + true + ) + } + + const handleAddMessagePushFilterSession = async (username: string) => { + if (messagePushFilterList.includes(username)) return + const next = [...messagePushFilterList, username] + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage('已添加到主动推送过滤列表', true) + } + + const handleRemoveMessagePushFilterSession = async (username: string) => { + const next = messagePushFilterList.filter(item => item !== username) + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage('已从主动推送过滤列表移除', true) + } + + const handleAddAllMessagePushFilterSessions = async () => { + const usernames = messagePushAvailableSessions.map(session => session.username) + if (usernames.length === 0) return + const next = Array.from(new Set([...messagePushFilterList, ...usernames])) + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage(`已添加 ${usernames.length} 个会话`, true) + } + + const messagePushOptionMap = new Map() + + for (const session of chatSessions) { + if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue + messagePushOptionMap.set(session.username, { + username: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl, + type: getMessagePushSessionType(session) + }) + } + + for (const contact of messagePushContactOptions) { + if (!contact.username) continue + if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue + const existing = messagePushOptionMap.get(contact.username) + messagePushOptionMap.set(contact.username, { + username: contact.username, + displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username, + avatarUrl: existing?.avatarUrl || contact.avatarUrl, + type: getMessagePushSessionType(contact) + }) + } + + const messagePushOptions = Array.from(messagePushOptionMap.values()) + .sort((a, b) => { + const aSession = chatSessions.find(session => session.username === a.username) + const bSession = chatSessions.find(session => session.username === b.username) + return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) - + Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0) + }) + + const messagePushAvailableSessions = messagePushOptions.filter(session => { + if (messagePushFilterList.includes(session.username)) return false + if (messagePushTypeFilter !== 'all' && session.type !== messagePushTypeFilter) return false + if (messagePushFilterSearchKeyword.trim()) { + const keyword = messagePushFilterSearchKeyword.trim().toLowerCase() + return String(session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + } + return true + }) + + const getMessagePushOptionInfo = (username: string) => { + return messagePushOptionMap.get(username) || { + username, + displayName: username, + avatarUrl: undefined, + type: 'other' as configService.MessagePushSessionType + } + } + const handleTestInsightConnection = async () => { setIsTestingInsight(true) setInsightTestResult(null) @@ -3350,6 +3476,151 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { +
+ + 选择只推送特定会话,或屏蔽特定会话 +
+
setMessagePushFilterDropdownOpen(!messagePushFilterDropdownOpen)} + > + + {messagePushFilterMode === 'all' ? '推送所有会话' : + messagePushFilterMode === 'whitelist' ? '仅推送白名单' : '屏蔽黑名单'} + + +
+
+ {[ + { value: 'all', label: '推送所有会话' }, + { value: 'whitelist', label: '仅推送白名单' }, + { value: 'blacklist', label: '屏蔽黑名单' } + ].map(option => ( +
{ void handleSetMessagePushFilterMode(option.value as configService.MessagePushFilterMode) }} + > + {option.label} + {messagePushFilterMode === option.value && } +
+ ))} +
+
+
+ + {messagePushFilterMode !== 'all' && ( +
+ + + {messagePushFilterMode === 'whitelist' + ? '点击左侧会话添加到白名单,只有白名单会话会推送' + : '点击左侧会话添加到黑名单,黑名单会话不会推送'} + +
+ {[ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' }, + { value: 'other', label: '其他/非好友' } + ].map(option => ( + + ))} +
+
+
+
+ 可选会话 + {messagePushAvailableSessions.length > 0 && ( + + )} +
+ + setMessagePushFilterSearchKeyword(e.target.value)} + /> +
+
+
+ {messagePushAvailableSessions.length > 0 ? ( + messagePushAvailableSessions.map(session => ( +
{ void handleAddMessagePushFilterSession(session.username) }} + > + + {session.displayName || session.username} + {getMessagePushTypeLabel(session.type)} + + +
+ )) + ) : ( +
+ {messagePushFilterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} +
+ )} +
+
+ +
+
+ {messagePushFilterMode === 'whitelist' ? '白名单' : '黑名单'} + {messagePushFilterList.length > 0 && ( + {messagePushFilterList.length} + )} +
+
+ {messagePushFilterList.length > 0 ? ( + messagePushFilterList.map(username => { + const session = getMessagePushOptionInfo(username) + return ( +
{ void handleRemoveMessagePushFilterSession(username) }} + > + + {session.displayName || username} + {getMessagePushTypeLabel(session.type)} + × +
+ ) + }) + ) : ( +
尚未添加任何会话
+ )} +
+
+
+
+ )} +
外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务` @@ -3384,7 +3655,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {

通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。

- {['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( + {['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( {param} diff --git a/src/services/config.ts b/src/services/config.ts index ce6bb1e..afbbee4 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -72,6 +72,8 @@ export const CONFIG_KEYS = { HTTP_API_PORT: 'httpApiPort', HTTP_API_HOST: 'httpApiHost', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', + MESSAGE_PUSH_FILTER_MODE: 'messagePushFilterMode', + MESSAGE_PUSH_FILTER_LIST: 'messagePushFilterList', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', QUOTE_LAYOUT: 'quoteLayout', @@ -1505,6 +1507,29 @@ export async function setMessagePushEnabled(enabled: boolean): Promise { await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled) } +export type MessagePushFilterMode = 'all' | 'whitelist' | 'blacklist' +export type MessagePushSessionType = 'private' | 'group' | 'official' | 'other' + +export async function getMessagePushFilterMode(): Promise { + const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE) + if (value === 'whitelist' || value === 'blacklist') return value + return 'all' +} + +export async function setMessagePushFilterMode(mode: MessagePushFilterMode): Promise { + await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE, mode) +} + +export async function getMessagePushFilterList(): Promise { + const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST) + return Array.isArray(value) ? value.map(item => String(item || '').trim()).filter(Boolean) : [] +} + +export async function setMessagePushFilterList(list: string[]): Promise { + const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean))) + await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST, normalized) +} + export async function getWindowCloseBehavior(): Promise { const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR) if (value === 'tray' || value === 'quit') return value From 6359123323b3cbc7fb2ca39a4ee207921d9a2e9b Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 12 Apr 2026 08:11:20 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E6=8E=A5?= =?UTF-8?q?=E9=BE=99=E7=9A=84=E6=B6=88=E6=81=AF=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 6 ++ electron/services/exportService.ts | 3 + src/pages/ChatPage.scss | 135 +++++++++++++++++++++++++++++ src/pages/ChatPage.tsx | 111 ++++++++++++++++++++++++ 4 files changed, 255 insertions(+) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 9c06ed2..e6da68f 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -4670,6 +4670,8 @@ class ChatService { case '57': // 引用消息,title 就是回复的内容 return title + case '53': + return `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` case '2000': return `[转账] ${title}` case '2001': @@ -4699,6 +4701,8 @@ class ChatService { return '[链接]' case '87': return '[群公告]' + case '53': + return '[接龙]' default: return '[消息]' } @@ -5298,6 +5302,8 @@ class ChatService { const quoteInfo = this.parseQuoteMessage(content) result.quotedContent = quoteInfo.content result.quotedSender = quoteInfo.sender + } else if (xmlType === '53') { + result.appMsgKind = 'solitaire' } else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) { result.appMsgKind = 'official-link' } else if (url) { diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index d13458c..2717718 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -2119,6 +2119,7 @@ class ExportService { } return title || '[引用消息]' } + if (xmlType === '53') return title ? `[接龙] ${title.split(/\r?\n/).map(line => line.trim()).find(Boolean) || title}` : '[接龙]' if (xmlType === '5' || xmlType === '49') return title ? `[链接] ${title}` : '[链接]' // 有 title 就返回 title @@ -3220,6 +3221,8 @@ class ExportService { appMsgKind = 'announcement' } else if (xmlType === '57' || hasReferMsg || localType === 244813135921) { appMsgKind = 'quote' + } else if (xmlType === '53') { + appMsgKind = 'solitaire' } else if (xmlType === '5' || xmlType === '49') { appMsgKind = 'link' } else if (looksLikeAppMsg) { diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 22d2e56..60b99ee 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2064,6 +2064,7 @@ .message-bubble .bubble-content:has(> .link-message), .message-bubble .bubble-content:has(> .card-message), .message-bubble .bubble-content:has(> .chat-record-message), +.message-bubble .bubble-content:has(> .solitaire-message), .message-bubble .bubble-content:has(> .official-message), .message-bubble .bubble-content:has(> .channel-video-card), .message-bubble .bubble-content:has(> .location-message) { @@ -3604,6 +3605,140 @@ } } +// 接龙消息 +.solitaire-message { + width: min(360px, 72vw); + max-width: 360px; + background: var(--card-inner-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + } + + .solitaire-header { + display: flex; + gap: 10px; + padding: 12px 14px 10px; + border-bottom: 1px solid var(--border-color); + } + + .solitaire-icon { + width: 30px; + height: 30px; + border-radius: 8px; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .solitaire-heading { + min-width: 0; + flex: 1; + } + + .solitaire-title { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .solitaire-meta { + margin-top: 2px; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.4; + } + + .solitaire-intro, + .solitaire-entry-list { + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); + } + + .solitaire-intro { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; + } + + .solitaire-intro-line { + white-space: pre-wrap; + word-break: break-word; + } + + .solitaire-entry-list { + display: flex; + flex-direction: column; + gap: 7px; + } + + .solitaire-entry { + display: flex; + gap: 8px; + align-items: flex-start; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; + } + + .solitaire-entry-index { + width: 22px; + height: 22px; + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 11px; + } + + .solitaire-entry-text { + min-width: 0; + flex: 1; + word-break: break-word; + } + + .solitaire-muted-line { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + } + + .solitaire-footer { + padding: 8px 14px 10px; + color: var(--text-tertiary); + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .solitaire-chevron { + transition: transform 0.2s ease; + } + + &.expanded .solitaire-chevron { + transform: rotate(180deg); + } +} + // 通话消息 .call-message { display: flex; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 7c94600..7af1bc4 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -181,6 +181,51 @@ function buildChatRecordPreviewItems(recordList: ChatRecordItem[], maxVisible = ] } +interface SolitaireEntry { + index: string + text: string +} + +interface SolitaireContent { + title: string + introLines: string[] + entries: SolitaireEntry[] +} + +function parseSolitaireContent(rawTitle: string): SolitaireContent { + const lines = String(rawTitle || '') + .replace(/\r\n/g, '\n') + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + + const title = lines[0] || '接龙' + const introLines: string[] = [] + const entries: SolitaireEntry[] = [] + let hasStartedEntries = false + + for (const line of lines.slice(1)) { + const entryMatch = /^(\d+)[..、]\s*(.+)$/.exec(line) + if (entryMatch) { + hasStartedEntries = true + entries.push({ + index: entryMatch[1], + text: entryMatch[2].trim() + }) + continue + } + + if (hasStartedEntries && entries.length > 0) { + const previous = entries[entries.length - 1] + previous.text = `${previous.text} ${line}`.trim() + } else { + introLines.push(line) + } + } + + return { title, introLines, entries } +} + function composeGlobalMsgSearchResults( seedMap: Map, authoritativeMap: Map @@ -7825,6 +7870,7 @@ function MessageBubble({ const [senderName, setSenderName] = useState(undefined) const [quotedSenderName, setQuotedSenderName] = useState(undefined) const [quoteLayout, setQuoteLayout] = useState('quote-top') + const [solitaireExpanded, setSolitaireExpanded] = useState(false) const senderProfileRequestSeqRef = useRef(0) const [emojiError, setEmojiError] = useState(false) const [emojiLoading, setEmojiLoading] = useState(false) @@ -9433,6 +9479,71 @@ function MessageBubble({ ) } + if (xmlType === '53' || message.appMsgKind === 'solitaire') { + const solitaireText = message.linkTitle || q('appmsg > title') || q('title') || cleanedParsedContent || '接龙' + const solitaire = parseSolitaireContent(solitaireText) + const previewEntries = solitaireExpanded ? solitaire.entries : solitaire.entries.slice(0, 3) + const hiddenEntryCount = Math.max(0, solitaire.entries.length - previewEntries.length) + const introLines = solitaireExpanded ? solitaire.introLines : solitaire.introLines.slice(0, 4) + const hasMoreIntro = !solitaireExpanded && solitaire.introLines.length > introLines.length + const countText = solitaire.entries.length > 0 ? `${solitaire.entries.length} 人参与` : '接龙消息' + + return ( +
{ + e.stopPropagation() + setSolitaireExpanded(value => !value) + }} + onKeyDown={isSelectionMode ? undefined : (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + e.stopPropagation() + setSolitaireExpanded(value => !value) + }} + title={solitaireExpanded ? '点击收起接龙' : '点击展开接龙'} + > +
+ +
+
{solitaire.title}
+
{countText}
+
+
+ {introLines.length > 0 && ( +
+ {introLines.map((line, index) => ( +
{line}
+ ))} + {hasMoreIntro &&
...
} +
+ )} + {previewEntries.length > 0 ? ( +
+ {previewEntries.map(entry => ( +
+ {entry.index} + {entry.text} +
+ ))} + {hiddenEntryCount > 0 && ( +
还有 {hiddenEntryCount} 条...
+ )} +
+ ) : null} +
+ {solitaireExpanded ? '收起接龙' : '展开接龙'} + +
+
+ ) + } + const title = message.linkTitle || q('title') || cleanedParsedContent || 'Card' const desc = message.appMsgDesc || q('des') const url = message.linkUrl || q('url') From b7cb2cd42d473bdfcdd8990a0cea28ce32a022c0 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 12 Apr 2026 08:20:54 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=86=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=BC=9A=E8=AF=9D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SettingsPage.tsx | 226 ++++++++++++++++++++++++------------- 1 file changed, 149 insertions(+), 77 deletions(-) diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6b05c7a..770d0f8 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -72,6 +72,25 @@ interface WxidOption { avatarUrl?: string } +type SessionFilterType = configService.MessagePushSessionType +type SessionFilterTypeValue = 'all' | SessionFilterType +type SessionFilterMode = 'all' | 'whitelist' | 'blacklist' + +interface SessionFilterOption { + username: string + displayName: string + avatarUrl?: string + type: SessionFilterType +} + +const sessionFilterTypeOptions: Array<{ value: SessionFilterTypeValue; label: string }> = [ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' }, + { value: 'other', label: '其他/非好友' } +] + interface SettingsPageProps { onClose?: () => void } @@ -171,6 +190,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [quoteLayout, setQuoteLayout] = useState('quote-top') const [updateChannel, setUpdateChannel] = useState('stable') const [filterSearchKeyword, setFilterSearchKeyword] = useState('') + const [notificationTypeFilter, setNotificationTypeFilter] = useState('all') const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) const [closeBehaviorDropdownOpen, setCloseBehaviorDropdownOpen] = useState(false) @@ -230,7 +250,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [messagePushFilterList, setMessagePushFilterList] = useState([]) const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false) const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('') - const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<'all' | configService.MessagePushSessionType>('all') + const [messagePushTypeFilter, setMessagePushTypeFilter] = useState('all') const [messagePushContactOptions, setMessagePushContactOptions] = useState([]) const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState>(new Set()) @@ -1658,15 +1678,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { ) const renderNotificationTab = () => { - // 获取已过滤会话的信息 - const getSessionInfo = (username: string) => { - const session = chatSessions.find(s => s.username === username) - return { - displayName: session?.displayName || username, - avatarUrl: session?.avatarUrl || '' - } - } - // 添加会话到过滤列表 const handleAddToFilterList = async (username: string) => { if (notificationFilterList.includes(username)) return @@ -1684,18 +1695,6 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage('已从过滤列表移除', true) } - // 过滤掉已在列表中的会话,并根据搜索关键字过滤 - const availableSessions = chatSessions.filter(s => { - if (notificationFilterList.includes(s.username)) return false - if (filterSearchKeyword) { - const keyword = filterSearchKeyword.toLowerCase() - const displayName = (s.displayName || '').toLowerCase() - const username = s.username.toLowerCase() - return displayName.includes(keyword) || username.includes(keyword) - } - return true - }) - return (
@@ -1787,17 +1786,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{ - const val = option.value as 'all' | 'whitelist' | 'blacklist' - setNotificationFilterMode(val) - setFilterModeDropdownOpen(false) - await configService.setNotificationFilterMode(val) - showMessage( - val === 'all' ? '已设为接收所有通知' : - val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知', - true - ) - }} + onClick={() => { void handleSetNotificationFilterMode(option.value as SessionFilterMode) }} > {option.label} {notificationFilterMode === option.value && } @@ -1816,11 +1805,33 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { : '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'} +
+ {sessionFilterTypeOptions.map(option => ( + + ))} +
+
{/* 可选会话列表 */}
可选会话 + {notificationAvailableSessions.length > 0 && ( + + )}
- {availableSessions.length > 0 ? ( - availableSessions.map(session => ( + {notificationAvailableSessions.length > 0 ? ( + notificationAvailableSessions.map(session => (
{session.displayName || session.username} + {getSessionFilterTypeLabel(session.type)} +
)) ) : (
- {filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} + {filterSearchKeyword || notificationTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
)}
@@ -1863,11 +1875,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {notificationFilterList.length > 0 && ( {notificationFilterList.length} )} + {notificationFilterList.length > 0 && ( + + )}
{notificationFilterList.length > 0 ? ( notificationFilterList.map(username => { - const info = getSessionInfo(username) + const info = getSessionFilterOptionInfo(username) return (
{info.displayName} + {getSessionFilterTypeLabel(info.type)} ×
) @@ -2533,7 +2555,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) } - const getMessagePushSessionType = (session: { username: string; type?: ContactInfo['type'] | number }): configService.MessagePushSessionType => { + const getSessionFilterType = (session: { username: string; type?: ContactInfo['type'] | number }): SessionFilterType => { const username = String(session.username || '').trim() if (username.endsWith('@chatroom')) return 'group' if (username.startsWith('gh_') || session.type === 'official') return 'official' @@ -2542,7 +2564,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { return 'private' } - const getMessagePushTypeLabel = (type: configService.MessagePushSessionType) => { + const getSessionFilterTypeLabel = (type: SessionFilterType) => { switch (type) { case 'private': return '私聊' case 'group': return '群聊' @@ -2586,36 +2608,38 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(`已添加 ${usernames.length} 个会话`, true) } - const messagePushOptionMap = new Map() + const handleRemoveAllMessagePushFilterSessions = async () => { + if (messagePushFilterList.length === 0) return + setMessagePushFilterList([]) + await configService.setMessagePushFilterList([]) + showMessage('已清空主动推送过滤列表', true) + } + + const sessionFilterOptionMap = new Map() for (const session of chatSessions) { if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue - messagePushOptionMap.set(session.username, { + sessionFilterOptionMap.set(session.username, { username: session.username, displayName: session.displayName || session.username, avatarUrl: session.avatarUrl, - type: getMessagePushSessionType(session) + type: getSessionFilterType(session) }) } for (const contact of messagePushContactOptions) { if (!contact.username) continue if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue - const existing = messagePushOptionMap.get(contact.username) - messagePushOptionMap.set(contact.username, { + const existing = sessionFilterOptionMap.get(contact.username) + sessionFilterOptionMap.set(contact.username, { username: contact.username, displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username, avatarUrl: existing?.avatarUrl || contact.avatarUrl, - type: getMessagePushSessionType(contact) + type: getSessionFilterType(contact) }) } - const messagePushOptions = Array.from(messagePushOptionMap.values()) + const sessionFilterOptions = Array.from(sessionFilterOptionMap.values()) .sort((a, b) => { const aSession = chatSessions.find(session => session.username === a.username) const bSession = chatSessions.find(session => session.username === b.username) @@ -2623,26 +2647,71 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0) }) - const messagePushAvailableSessions = messagePushOptions.filter(session => { - if (messagePushFilterList.includes(session.username)) return false - if (messagePushTypeFilter !== 'all' && session.type !== messagePushTypeFilter) return false - if (messagePushFilterSearchKeyword.trim()) { - const keyword = messagePushFilterSearchKeyword.trim().toLowerCase() - return String(session.displayName || '').toLowerCase().includes(keyword) || - session.username.toLowerCase().includes(keyword) - } - return true - }) - - const getMessagePushOptionInfo = (username: string) => { - return messagePushOptionMap.get(username) || { + const getSessionFilterOptionInfo = (username: string) => { + return sessionFilterOptionMap.get(username) || { username, displayName: username, avatarUrl: undefined, - type: 'other' as configService.MessagePushSessionType + type: 'other' as SessionFilterType } } + const getAvailableSessionFilterOptions = ( + selectedList: string[], + typeFilter: SessionFilterTypeValue, + searchKeyword: string + ) => { + const keyword = searchKeyword.trim().toLowerCase() + return sessionFilterOptions.filter(session => { + if (selectedList.includes(session.username)) return false + if (typeFilter !== 'all' && session.type !== typeFilter) return false + if (keyword) { + return String(session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + } + return true + }) + } + + const notificationAvailableSessions = getAvailableSessionFilterOptions( + notificationFilterList, + notificationTypeFilter, + filterSearchKeyword + ) + + const messagePushAvailableSessions = getAvailableSessionFilterOptions( + messagePushFilterList, + messagePushTypeFilter, + messagePushFilterSearchKeyword + ) + + const handleAddAllNotificationFilterSessions = async () => { + const usernames = notificationAvailableSessions.map(session => session.username) + if (usernames.length === 0) return + const next = Array.from(new Set([...notificationFilterList, ...usernames])) + setNotificationFilterList(next) + await configService.setNotificationFilterList(next) + showMessage(`已添加 ${usernames.length} 个会话`, true) + } + + const handleRemoveAllNotificationFilterSessions = async () => { + if (notificationFilterList.length === 0) return + setNotificationFilterList([]) + await configService.setNotificationFilterList([]) + showMessage('已清空通知过滤列表', true) + } + + const handleSetNotificationFilterMode = async (mode: SessionFilterMode) => { + setNotificationFilterMode(mode) + setFilterModeDropdownOpen(false) + await configService.setNotificationFilterMode(mode) + showMessage( + mode === 'all' ? '已设为接收所有通知' : + mode === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知', + true + ) + } + const handleTestInsightConnection = async () => { setIsTestingInsight(true) setInsightTestResult(null) @@ -3518,18 +3587,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { : '点击左侧会话添加到黑名单,黑名单会话不会推送'}
- {[ - { value: 'all', label: '全部' }, - { value: 'private', label: '私聊' }, - { value: 'group', label: '群聊' }, - { value: 'official', label: '订阅号/服务号' }, - { value: 'other', label: '其他/非好友' } - ].map(option => ( + {sessionFilterTypeOptions.map(option => ( @@ -3572,13 +3635,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { size={28} /> {session.displayName || session.username} - {getMessagePushTypeLabel(session.type)} + {getSessionFilterTypeLabel(session.type)} +
)) ) : (
- {messagePushFilterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} + {messagePushFilterSearchKeyword || messagePushTypeFilter !== 'all' ? '没有匹配的会话' : '暂无可添加的会话'}
)}
@@ -3590,11 +3653,20 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { {messagePushFilterList.length > 0 && ( {messagePushFilterList.length} )} + {messagePushFilterList.length > 0 && ( + + )}
{messagePushFilterList.length > 0 ? ( messagePushFilterList.map(username => { - const session = getMessagePushOptionInfo(username) + const session = getSessionFilterOptionInfo(username) return (
{session.displayName || username} - {getMessagePushTypeLabel(session.type)} + {getSessionFilterTypeLabel(session.type)} ×
) From 7e1ca95bef7cb4476a981cab01bf8fb9dce51fb7 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 12 Apr 2026 08:36:13 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E9=A1=B5=E5=A4=B4=E5=83=8F=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ExportPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1f95d36..e5190d3 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6866,6 +6866,7 @@ function ExportPage() { const nextCanExport = Boolean(nextContact && sessionRowByUsername.get(nextContact.username)?.hasSession) const previousSelected = Boolean(previousContact && previousCanExport && selectedSessions.has(previousContact.username)) const nextSelected = Boolean(nextContact && nextCanExport && selectedSessions.has(nextContact.username)) + const resolvedAvatarUrl = normalizeExportAvatarUrl(matchedSession?.avatarUrl || contact.avatarUrl) const rowClassName = [ 'contact-row', checked ? 'selected' : '', @@ -6889,7 +6890,7 @@ function ExportPage() {
Date: Sun, 12 Apr 2026 13:27:18 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E9=80=82=E9=85=8D=E6=9B=B4=E5=A4=9Awecha?= =?UTF-8?q?t=E8=B7=AF=E5=BE=84=EF=BC=8C=E4=BC=98=E5=8C=96=E6=8B=89?= =?UTF-8?q?=E8=B5=B7=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/keyServiceLinux.ts | 9 +++++++-- src/pages/SettingsPage.tsx | 12 +++++++++--- src/pages/WelcomePage.tsx | 12 +++++++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/electron/services/keyServiceLinux.ts b/electron/services/keyServiceLinux.ts index 85d5a36..0e94d6c 100644 --- a/electron/services/keyServiceLinux.ts +++ b/electron/services/keyServiceLinux.ts @@ -98,7 +98,12 @@ export class KeyServiceLinux { 'xwechat', '/opt/wechat/wechat', '/usr/bin/wechat', - '/opt/apps/com.tencent.wechat/files/wechat' + '/usr/local/bin/wechat', + '/usr/bin/wechat', + '/opt/apps/com.tencent.wechat/files/wechat', + '/usr/bin/wechat-bin', + '/usr/local/bin/wechat-bin', + 'com.tencent.wechat' ] for (const binName of wechatBins) { @@ -152,7 +157,7 @@ export class KeyServiceLinux { } if (!pid) { - const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动并登录。' + const err = '未能自动启动微信,或获取PID失败,请查看控制台日志或手动启动微信,看到登录窗口后点击确认。' onStatus?.(err, 2) return { success: false, error: err } } diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 770d0f8..48f0ae2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1222,7 +1222,13 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const keysOverride = buildKeysFromInputs({ decryptKey: result.key }) await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride }) } else { - if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { + if ( + result.error?.includes('未找到微信安装路径') || + result.error?.includes('启动微信失败') || + result.error?.includes('未能自动启动微信') || + result.error?.includes('未找到微信进程') || + result.error?.includes('微信进程未运行') + ) { setIsManualStartPrompt(true) setDbKeyStatus('需要手动启动微信') } else { @@ -2146,9 +2152,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
{isManualStartPrompt ? (
-

未能自动启动微信,请手动启动并登录后点击下方确认

+

未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认

) : ( diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 2f4bab7..4e83843 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -368,7 +368,13 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { setError('') await handleScanWxid(true) } else { - if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { + if ( + result.error?.includes('未找到微信安装路径') || + result.error?.includes('启动微信失败') || + result.error?.includes('未能自动启动微信') || + result.error?.includes('未找到微信进程') || + result.error?.includes('微信进程未运行') + ) { setIsManualStartPrompt(true) setDbKeyStatus('需要手动启动微信') } else { @@ -844,9 +850,9 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{isManualStartPrompt ? (
-

未能自动启动微信,请手动启动并登录

+

未能自动启动微信,请手动启动微信,看到登录窗口后点击下方确认

) : (