吸收数字员工通知权限引导

This commit is contained in:
baiyanyun
2026-06-12 10:16:02 +08:00
parent e57c94c19d
commit e473b5c147
17 changed files with 642 additions and 204 deletions

View File

@@ -1,17 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, Check, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
import { Bell, BellRing, Check, ExternalLink, Mail, MessageSquare, RefreshCw, Settings2, X } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { RoleProvider } from '@/contexts/RoleContext';
import {
dismissNotification,
getDesktopNotificationStatus,
getNotificationPreferences,
getNotifications,
getUnreadNotificationCount,
markNotificationRead,
openDesktopNotificationSettings,
patchNotificationPreferences,
pullNotificationUpdates,
replyToNotification,
showDesktopNotification,
type DesktopNotificationShowResult,
type DesktopNotificationStatus,
type NotificationPreferences,
type UserNotification,
} from '@/lib/api';
@@ -56,6 +60,12 @@ const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
const DESKTOP_NOTIFICATION_DEDUPE_KEY = 'qimingclaw:digital-desktop-notified:v1';
const DESKTOP_NOTIFICATION_LEVELS = new Set<UserNotification['level']>(['action', 'warn', 'error']);
type DesktopNotificationFeedback = {
tone: 'success' | 'warning';
message: string;
detail?: string | null;
};
function isDigitalTab(value: string | null): value is DigitalTab {
return Boolean(value && TABS.some(tab => tab.id === value));
}
@@ -126,6 +136,18 @@ function notificationTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
}
function desktopNotificationReasonLabel(reason?: string | null): string | null {
if (!reason) return null;
if (reason === 'notification_unavailable') return '系统桌面通知不可用或未授权';
if (reason === 'notification_settings_unavailable') return '当前平台无法自动打开系统通知设置';
if (reason === 'qimingclaw_bridge_unavailable') return '客户端通知桥接尚不可用';
return reason;
}
function desktopNotificationResultDetail(result: Pick<DesktopNotificationShowResult, 'reason' | 'error'>): string {
return desktopNotificationReasonLabel(result.reason) ?? result.error ?? '通知未能发送';
}
function readDesktopNotificationDedupe(): Set<string> {
try {
const raw = window.localStorage.getItem(DESKTOP_NOTIFICATION_DEDUPE_KEY);
@@ -152,23 +174,28 @@ function notificationMatchesDesktopPreferences(notification: UserNotification, p
return !preferences.muted_kinds.includes(notification.kind);
}
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<void> {
async function showUnreadDesktopNotifications(notifications: UserNotification[], preferences: NotificationPreferences): Promise<DesktopNotificationShowResult | null> {
const notified = readDesktopNotificationDedupe();
const candidates = notifications.filter((notification) => (
notificationMatchesDesktopPreferences(notification, preferences)
&& !notified.has(notification.notification_id)
));
if (candidates.length === 0) return;
if (candidates.length === 0) return null;
candidates.forEach((notification) => notified.add(notification.notification_id));
writeDesktopNotificationDedupe(notified);
await Promise.all(candidates.slice(0, 3).map((notification) => showDesktopNotification({
const results = await Promise.all(candidates.slice(0, 3).map((notification) => showDesktopNotification({
notification_id: notification.notification_id,
title: notification.title,
body: notification.body,
level: notification.level,
kind: notification.kind,
status: notification.status,
}).catch(() => null)));
}).catch((error): DesktopNotificationShowResult => ({
ok: false,
shown: false,
error: error instanceof Error ? error.message : String(error),
}))));
return results.find((result) => result.ok === false || result.shown !== true) ?? null;
}
export default function DigitalEmployeeShell() {
@@ -182,8 +209,29 @@ export default function DigitalEmployeeShell() {
const [notificationsSyncing, setNotificationsSyncing] = useState(false);
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false);
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>(DEFAULT_NOTIFICATION_PREFERENCES);
const [desktopNotificationStatus, setDesktopNotificationStatus] = useState<DesktopNotificationStatus | null>(null);
const [desktopNotificationStatusLoading, setDesktopNotificationStatusLoading] = useState(false);
const [desktopNotificationTesting, setDesktopNotificationTesting] = useState(false);
const [desktopNotificationSettingsOpening, setDesktopNotificationSettingsOpening] = useState(false);
const [desktopNotificationFeedback, setDesktopNotificationFeedback] = useState<DesktopNotificationFeedback | null>(null);
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
const refreshDesktopNotificationStatus = useCallback(async () => {
setDesktopNotificationStatusLoading(true);
try {
setDesktopNotificationStatus(await getDesktopNotificationStatus());
} catch (error) {
setDesktopNotificationStatus({
supported: false,
platform: 'unknown',
can_open_settings: false,
reason: error instanceof Error ? error.message : String(error),
});
} finally {
setDesktopNotificationStatusLoading(false);
}
}, []);
const refreshNotifications = useCallback(async () => {
setNotificationsLoading(true);
try {
@@ -195,7 +243,15 @@ export default function DigitalEmployeeShell() {
setNotifications(items);
setUnreadCount(count);
setNotificationPreferences(preferences);
void showUnreadDesktopNotifications(items, preferences);
void showUnreadDesktopNotifications(items, preferences).then((failure) => {
if (failure) {
setDesktopNotificationFeedback({
tone: 'warning',
message: '自动桌面通知发送失败',
detail: desktopNotificationResultDetail(failure),
});
}
});
} catch {
setNotifications([]);
setUnreadCount(0);
@@ -213,6 +269,12 @@ export default function DigitalEmployeeShell() {
void refreshNotifications();
}, [refreshNotifications]);
useEffect(() => {
if (notificationSettingsOpen) {
void refreshDesktopNotificationStatus();
}
}, [notificationSettingsOpen, refreshDesktopNotificationStatus]);
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
setTarget(nextTarget);
const params = new URLSearchParams();
@@ -276,6 +338,63 @@ export default function DigitalEmployeeShell() {
void updateNotificationPreferences({ muted_kinds: Array.from(muted) });
}, [notificationPreferences.muted_kinds, updateNotificationPreferences]);
const handleDesktopNotificationTest = useCallback(async () => {
setDesktopNotificationTesting(true);
try {
const result = await showDesktopNotification({
notification_id: `desktop-notification-test-${Date.now()}`,
title: '数字员工桌面通知测试',
body: '如果看到这条通知,系统桌面通知入口已可用。',
level: 'info',
kind: 'task_progress',
status: 'unread',
});
setDesktopNotificationFeedback(result.ok !== false && result.shown === true
? { tone: 'success', message: '测试桌面通知已发送', detail: null }
: { tone: 'warning', message: '测试桌面通知发送失败', detail: desktopNotificationResultDetail(result) });
await refreshDesktopNotificationStatus();
} catch (error) {
setDesktopNotificationFeedback({
tone: 'warning',
message: '测试桌面通知发送失败',
detail: error instanceof Error ? error.message : String(error),
});
} finally {
setDesktopNotificationTesting(false);
}
}, [refreshDesktopNotificationStatus]);
const handleDesktopNotificationSettingsOpen = useCallback(async () => {
setDesktopNotificationSettingsOpening(true);
try {
const result = await openDesktopNotificationSettings();
setDesktopNotificationFeedback(result.success
? { tone: 'success', message: '已打开系统设置', detail: null }
: { tone: 'warning', message: '无法打开系统设置', detail: desktopNotificationReasonLabel(result.reason) ?? result.error ?? '当前平台不支持自动打开' });
await refreshDesktopNotificationStatus();
} catch (error) {
setDesktopNotificationFeedback({
tone: 'warning',
message: '无法打开系统设置',
detail: error instanceof Error ? error.message : String(error),
});
} finally {
setDesktopNotificationSettingsOpening(false);
}
}, [refreshDesktopNotificationStatus]);
const desktopNotificationTone = desktopNotificationFeedback?.tone === 'warning' || desktopNotificationStatus?.supported === false ? 'warning' : 'success';
const desktopNotificationMessage = desktopNotificationFeedback?.message
?? (desktopNotificationStatusLoading
? '正在检查桌面通知'
: desktopNotificationStatus?.supported === false
? '系统桌面通知不可用'
: '系统桌面通知可用');
const desktopNotificationDetail = desktopNotificationFeedback?.detail
?? desktopNotificationReasonLabel(desktopNotificationStatus?.reason)
?? (desktopNotificationStatus?.platform ? `平台:${desktopNotificationStatus.platform}` : null);
const shouldShowDesktopSettingsButton = desktopNotificationStatus?.can_open_settings === true || desktopNotificationTone === 'warning';
return (
<RoleProvider>
<div className="de-shell">
@@ -363,6 +482,37 @@ export default function DigitalEmployeeShell() {
/>
<span></span>
</label>
<div className={`de-notification-desktop-status de-notification-desktop-status--${desktopNotificationTone}`}>
<div className="de-notification-desktop-copy">
<span></span>
<strong>{desktopNotificationMessage}</strong>
{desktopNotificationDetail && <small>{desktopNotificationDetail}</small>}
</div>
<div className="de-notification-desktop-actions">
<button
type="button"
className="de-notification-desktop-btn"
title="测试桌面通知"
onClick={() => { void handleDesktopNotificationTest(); }}
disabled={desktopNotificationTesting}
>
<BellRing size={13} />
<span></span>
</button>
{shouldShowDesktopSettingsButton && (
<button
type="button"
className="de-notification-desktop-btn"
title="打开系统设置"
onClick={() => { void handleDesktopNotificationSettingsOpen(); }}
disabled={desktopNotificationSettingsOpening}
>
<ExternalLink size={13} />
<span></span>
</button>
)}
</div>
</div>
<div className="de-notification-preference-group">
<span></span>
<div className="de-notification-segment-row">