吸收数字员工通知权限引导
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'>;
|
||||
|
||||
Reference in New Issue
Block a user