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

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

@@ -677,6 +677,36 @@
}
.de-notification-switch input,
.de-notification-toggle-grid input { accent-color: #1684d8; }
.de-notification-desktop-status {
display: grid;
gap: 9px;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(61,132,184,0.14);
background: #fff;
}
.de-notification-desktop-status--success { border-color: rgba(35,128,93,0.2); background: rgba(240,250,246,0.9); }
.de-notification-desktop-status--warning { border-color: rgba(204,130,31,0.24); background: rgba(255,248,235,0.92); }
.de-notification-desktop-copy { min-width: 0; display: grid; gap: 3px; }
.de-notification-desktop-copy span { color: #7890a7; font-size: 11px; font-weight: 800; }
.de-notification-desktop-copy strong { color: #12344f; font-size: 13px; line-height: 1.35; }
.de-notification-desktop-copy small { color: #6a8094; font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.de-notification-desktop-actions { display: flex; flex-wrap: wrap; gap: 7px; }
.de-notification-desktop-btn {
min-height: 28px;
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid rgba(61,132,184,0.16);
border-radius: 999px;
padding: 0 10px;
color: #31526d;
background: rgba(247,251,255,0.96);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.de-notification-desktop-btn:disabled { cursor: not-allowed; opacity: 0.62; }
.de-notification-preference-group { display: grid; gap: 7px; }
.de-notification-preference-group > span { color: #7890a7; font-size: 11px; font-weight: 800; }
.de-notification-segment-row,

View File

@@ -19,6 +19,9 @@ import type {
PlanStepDispatchPolicyPullResult,
ReadyStepDispatchResult,
RiskApprovalPolicy,
DesktopNotificationSettingsOpenResult,
DesktopNotificationShowResult,
DesktopNotificationStatus,
NotificationPreferences,
NotificationUpdatesPullResult,
UserNotification,
@@ -569,7 +572,7 @@ export function getEventLog(params: {
// Notifications
// ---------------------------------------------------------------------------
export { type ApprovalSubscriptionPreferences, type NotificationPreferences, type NotificationUpdatesPullResult, type PlanStepDispatchPolicy, type RiskApprovalPolicy, type UserNotification };
export { type ApprovalSubscriptionPreferences, type DesktopNotificationSettingsOpenResult, type DesktopNotificationShowResult, type DesktopNotificationStatus, type NotificationPreferences, type NotificationUpdatesPullResult, type PlanStepDispatchPolicy, type RiskApprovalPolicy, type UserNotification };
export function getNotifications(
status: string = 'unread',
@@ -604,13 +607,24 @@ export function pullNotificationUpdates(): Promise<NotificationUpdatesPullResult
});
}
export function showDesktopNotification(input: Pick<UserNotification, 'notification_id' | 'title' | 'body' | 'level' | 'kind' | 'status'>): Promise<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }> {
return apiFetch<{ ok: boolean; shown?: boolean; reason?: string | null; error?: string | null }>('/api/notifications/desktop', {
export function showDesktopNotification(input: Pick<UserNotification, 'notification_id' | 'title' | 'body' | 'level' | 'kind' | 'status'>): Promise<DesktopNotificationShowResult> {
return apiFetch<DesktopNotificationShowResult>('/api/notifications/desktop', {
method: 'POST',
body: JSON.stringify(input),
});
}
export function getDesktopNotificationStatus(): Promise<DesktopNotificationStatus> {
return apiFetch<DesktopNotificationStatus>('/api/notifications/desktop/status');
}
export function openDesktopNotificationSettings(): Promise<DesktopNotificationSettingsOpenResult> {
return apiFetch<DesktopNotificationSettingsOpenResult>('/api/notifications/desktop/settings/open', {
method: 'POST',
body: JSON.stringify({}),
});
}
export function getApprovalSubscriptionPreferences(): Promise<ApprovalSubscriptionPreferences> {
return apiFetch<ApprovalSubscriptionPreferences>('/api/approval/subscriptions');
}

View File

@@ -68,6 +68,8 @@ declare global {
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
submitNotificationAction?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
showDesktopNotification?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; shown?: boolean; reason?: string | null; error?: string | null }>;
getDesktopNotificationStatus?: () => Promise<{ supported?: boolean; platform?: string; can_open_settings?: boolean; reason?: string | null }>;
openDesktopNotificationSettings?: () => Promise<{ success?: boolean; platform?: string; reason?: string | null; error?: string | null }>;
evaluateRiskPolicy?: (input: QimingclawRiskPolicyInput) => Promise<QimingclawRiskPolicyResult>;
pullRouteDecisionPolicy?: () => Promise<RouteDecisionPolicyPullResult>;
evaluateRouteDecisionPolicy?: (input: QimingclawRouteDecisionPolicyInput) => Promise<QimingclawRouteDecisionPolicyResult>;
@@ -1046,6 +1048,12 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'POST' && pathname === '/api/notifications/desktop') {
return await showBridgeDesktopNotification(parseOptionalJsonBody(options.body)) as T;
}
if (method === 'GET' && pathname === '/api/notifications/desktop/status') {
return await readBridgeDesktopNotificationStatus() as T;
}
if (method === 'POST' && pathname === '/api/notifications/desktop/settings/open') {
return await openBridgeDesktopNotificationSettings() as T;
}
if (method === 'GET' && pathname === '/api/notifications/unread-count') {
const snapshot = await readQimingclawSnapshot();
return { count: buildNotifications(snapshot, notificationAdapterState(snapshot)).filter((notification) => notification.status === 'unread').length } as T;
@@ -1368,6 +1376,52 @@ async function showBridgeDesktopNotification(input: Record<string, unknown>): Pr
}
}
async function readBridgeDesktopNotificationStatus(): Promise<{ supported: boolean; platform: string; can_open_settings: boolean; reason?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.getDesktopNotificationStatus) {
return {
supported: false,
platform: 'unknown',
can_open_settings: false,
reason: 'qimingclaw_bridge_unavailable',
};
}
try {
const result = await bridge.getDesktopNotificationStatus();
return {
supported: result?.supported === true,
platform: stringValue(result?.platform) || 'unknown',
can_open_settings: result?.can_open_settings === true,
reason: stringValue(result?.reason) || null,
};
} catch (error) {
return {
supported: false,
platform: 'unknown',
can_open_settings: false,
reason: error instanceof Error ? error.message : String(error),
};
}
}
async function openBridgeDesktopNotificationSettings(): Promise<{ success: boolean; platform?: string | null; reason?: string | null; error?: string | null }> {
const bridge = window.QimingClawBridge?.digital;
if (!bridge?.openDesktopNotificationSettings) {
return { success: false, reason: 'qimingclaw_bridge_unavailable' };
}
try {
const result = await bridge.openDesktopNotificationSettings();
return {
success: result?.success === true,
platform: stringValue(result?.platform) || null,
reason: stringValue(result?.reason) || null,
error: stringValue(result?.error) || null,
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
async function resolveApprovedRouteInterventions(snapshot: QimingclawSnapshot | null): Promise<void> {
const timeline = await readRouteInterventions(120);
const approved = timeline.interventions.filter((item) => (

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">

View File

@@ -198,6 +198,27 @@ export interface NotificationUpdatesPullResult {
error?: string | null;
}
export interface DesktopNotificationStatus {
supported: boolean;
platform: string;
can_open_settings: boolean;
reason?: string | null;
}
export interface DesktopNotificationSettingsOpenResult {
success: boolean;
platform?: string | null;
reason?: string | null;
error?: string | null;
}
export interface DesktopNotificationShowResult {
ok: boolean;
shown?: boolean;
reason?: string | null;
error?: string | null;
}
export interface ApprovalSubscriptionPreferences {
enabled: boolean;
subscribed_actions: Array<'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'decision'>;