吸收数字员工通知投影

This commit is contained in:
baiyanyun
2026-06-09 18:33:01 +08:00
parent dc8cd09535
commit 23c3577ef4
10 changed files with 617 additions and 149 deletions

View File

@@ -612,8 +612,145 @@
color: #fff; background: rgba(255,255,255,0.12);
cursor: pointer; transition: background 0.18s ease, transform 0.18s ease;
}
.de-header-icon:hover { background: rgba(255,255,255,0.22); transform: translateY(-1px); }
.de-header-icon:hover, .de-header-icon.active { background: rgba(255,255,255,0.22); transform: translateY(-1px); }
.de-header-close { background: rgba(255,255,255,0.18); }
.de-notification-anchor { position: relative; display: inline-flex; align-items: center; }
.de-notification-count {
position: absolute;
top: -5px;
right: -8px;
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 999px;
background: #ff5d73;
color: #fff;
border: 1px solid rgba(255,255,255,0.82);
font-size: 10px;
font-weight: 800;
line-height: 15px;
box-shadow: 0 4px 12px rgba(198,33,70,0.32);
}
.de-notification-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 360;
width: min(380px, calc(100vw - 28px));
max-height: min(620px, calc(100vh - 88px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border-radius: 14px;
background: rgba(252,254,255,0.98);
border: 1px solid rgba(61,132,184,0.18);
box-shadow: 0 22px 60px rgba(18,52,79,0.24);
}
.de-notification-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border-bottom: 1px solid rgba(61,132,184,0.12);
}
.de-notification-head div { min-width: 0; display: grid; gap: 2px; }
.de-notification-head strong { color: #12344f; font-size: 15px; }
.de-notification-head span { color: #6a8094; font-size: 12px; }
.de-notification-close { width: 26px; height: 26px; color: #496a85; background: rgba(79,172,254,0.1); }
.de-notification-list {
min-height: 0;
overflow-y: auto;
display: grid;
gap: 10px;
padding: 12px;
}
.de-notification-empty {
min-height: 86px;
display: grid;
place-items: center;
color: #6a8094;
font-size: 13px;
}
.de-notification-item {
display: grid;
gap: 8px;
padding: 12px;
border-radius: 10px;
background: #fff;
border: 1px solid rgba(61,132,184,0.12);
}
.de-notification-item--action { border-color: rgba(79,172,254,0.3); }
.de-notification-item--warn { border-color: rgba(255,184,61,0.34); }
.de-notification-item--error { border-color: rgba(244,96,96,0.3); }
.de-notification-item-head,
.de-notification-title-row,
.de-notification-actions,
.de-notification-reply-row {
display: flex;
align-items: center;
gap: 8px;
}
.de-notification-item-head,
.de-notification-title-row { justify-content: space-between; }
.de-notification-level,
.de-notification-title-row span {
flex-shrink: 0;
min-height: 20px;
display: inline-flex;
align-items: center;
padding: 0 7px;
border-radius: 999px;
background: rgba(79,172,254,0.1);
color: #22659a;
font-size: 11px;
font-weight: 700;
}
.de-notification-time { color: #7890a7; font-size: 11px; }
.de-notification-title-row h3 {
min-width: 0;
margin: 0;
color: #12344f;
font-size: 14px;
line-height: 1.32;
}
.de-notification-item p,
.de-notification-reply {
margin: 0;
color: #536b80;
font-size: 12px;
line-height: 1.5;
}
.de-notification-reply {
padding: 8px;
border-radius: 8px;
background: rgba(79,172,254,0.08);
color: #31526d;
}
.de-notification-actions { justify-content: flex-end; }
.de-notification-icon-btn {
width: 28px;
height: 28px;
border: 1px solid rgba(61,132,184,0.16);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #31526d;
background: rgba(247,251,255,0.96);
cursor: pointer;
}
.de-notification-icon-btn:hover { background: rgba(79,172,254,0.14); }
.de-notification-reply-row input {
min-width: 0;
flex: 1;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(61,132,184,0.18);
padding: 0 9px;
color: #12344f;
background: #fff;
}
.de-digital-modal-close-hitbox {
position: absolute;
top: 8px;

View File

@@ -31,6 +31,7 @@ import type {
RouteDecisionRecord,
RouteDecisionTimelineResponse,
SkillSpec,
UserNotification,
} from '@/types/api';
declare global {
@@ -73,6 +74,7 @@ interface AdapterState {
reportScheduleTime: string;
reportGeneratedAtByDate: Record<string, string>;
decisionResultsByDate: Record<string, Record<string, string>>;
notificationStateById?: Record<string, NotificationState>;
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -83,6 +85,14 @@ interface AdapterState {
};
}
interface NotificationState {
status?: UserNotification['status'];
read_at?: string | null;
dismissed_at?: string | null;
reply?: string | null;
replied_at?: string | null;
}
interface QimingclawServiceStatus {
running?: boolean;
error?: string;
@@ -644,6 +654,22 @@ export async function handleQimingclawDigitalApi<T>(
if (method === 'GET' && approvalTimelineMatch) {
return buildApprovalTimeline(decodeURIComponent(approvalTimelineMatch[1] || ''), await readQimingclawSnapshot()) as T;
}
if (method === 'GET' && pathname === '/api/notifications') {
return buildNotificationsResponse(url.searchParams.get('status'), await readQimingclawSnapshot()) 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;
}
const notificationActionMatch = pathname.match(/^\/api\/notifications\/([^/]+)\/(read|dismiss|reply)$/);
if (method === 'POST' && notificationActionMatch) {
return await handleNotificationAction(
decodeURIComponent(notificationActionMatch[1] || ''),
notificationActionMatch[2] as 'read' | 'dismiss' | 'reply',
parseOptionalJsonBody(options.body),
await readQimingclawSnapshot(),
) as T;
}
if (method === 'GET' && pathname === '/api/memory') {
return { entries: await readMemoryEntries(url.searchParams.get('query'), url.searchParams.get('category')) } as T;
}
@@ -716,6 +742,7 @@ function defaultState(): AdapterState {
reportScheduleTime: DEFAULT_REPORT_TIME,
reportGeneratedAtByDate: {},
decisionResultsByDate: {},
notificationStateById: {},
consoleRole: 'sales',
rolePreferences: {},
profile: {
@@ -2069,6 +2096,161 @@ function dedupeApprovalTimelineEvents(events: ApprovalTimelineResponse['events']
});
}
function notificationAdapterState(snapshot: QimingclawSnapshot | null): AdapterState {
return snapshot?.uiState ?? readState();
}
function buildNotificationsResponse(status: string | null, snapshot: QimingclawSnapshot | null): { notifications: UserNotification[] } {
const notifications = buildNotifications(snapshot, notificationAdapterState(snapshot));
const normalizedStatus = (status || 'all').toLowerCase();
if (normalizedStatus === 'all') {
return { notifications: notifications.filter((notification) => notification.status !== 'dismissed') };
}
return { notifications: notifications.filter((notification) => notification.status === normalizedStatus) };
}
function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterState): UserNotification[] {
const notifications = dedupeNotifications([
...approvalNotifications(snapshot),
...syncFailureNotifications(snapshot),
...managedServiceNotifications(snapshot),
...taskNotifications(snapshot),
]);
const overlays = state.notificationStateById ?? {};
return notifications
.map((notification) => applyNotificationOverlay(notification, overlays[notification.notification_id]))
.sort((left, right) => right.created_at.localeCompare(left.created_at) || right.notification_id.localeCompare(left.notification_id));
}
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
return buildApprovals(snapshot)
.filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime')
.map((approval) => {
const status = stringValue(approval.status) || 'pending';
const open = isDecisionOpenStatus(status);
return {
notification_id: `approval:${stringValue(approval.approval_id)}`,
plan_id: stringValue(approval.plan_id),
task_id: stringValue(approval.task_id),
kind: open ? 'need_approval' : 'confirmation_resolved',
title: open ? '审批待处理' : '审批已处理',
body: stringValue(approval.title) || '待确认事项',
level: open ? 'action' : 'info',
status: open ? 'unread' : 'resolved',
correlation_id: stringValue(approval.approval_id),
created_at: stringValue(approval.created_at) || new Date().toISOString(),
} satisfies UserNotification;
});
}
function syncFailureNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
return (snapshot?.sync?.recentFailures ?? []).map((failure) => ({
notification_id: `sync_failure:${failure.id}`,
plan_id: '',
task_id: failure.entityId,
kind: 'task_failed',
title: `同步失败:${failure.entityType}`,
body: syncFailureLine(failure),
level: failure.dueForRetry === false ? 'error' : 'warn',
status: 'unread',
correlation_id: failure.id,
created_at: failure.lastAttemptAt ?? failure.updatedAt ?? failure.createdAt,
}));
}
function managedServiceNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
return buildManagedServices(snapshot)
.filter((service) => service.requires_human_action || service.status === 'degraded' || service.status === 'stopped')
.map((service) => ({
notification_id: `service:${service.service_id}`,
plan_id: '',
task_id: service.dependent_tasks?.[0] ?? '',
kind: 'need_input',
title: `服务需要处理:${service.name}`,
body: service.error || `${service.name} 当前状态:${service.status_label || service.status}`,
level: service.status === 'stopped' ? 'error' : 'warn',
status: 'unread',
correlation_id: service.service_id,
created_at: service.last_seen_at || new Date().toISOString(),
}));
}
function taskNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
return runtimeTasks(snapshot).flatMap((task) => {
const normalized = task.status.toLowerCase();
const success = ['completed', 'succeeded', 'success', 'done'].includes(normalized);
const failed = ['failed', 'error', 'rejected', 'cancelled', 'skipped'].includes(normalized);
if (!success && !failed) return [];
const timestamp = task.updatedAt || task.createdAt || new Date().toISOString();
return [{
notification_id: `task:${task.id}:${slugifyIdPart(timestamp)}`,
plan_id: task.planId ?? '',
task_id: task.id,
kind: success ? 'task_finished' : 'task_failed',
title: success ? '任务已完成' : '任务需要关注',
body: `${task.title}${taskStatusLabel(task.status)}`,
level: success ? 'info' : 'error',
status: 'unread',
correlation_id: task.id,
created_at: timestamp,
} satisfies UserNotification];
});
}
function applyNotificationOverlay(notification: UserNotification, overlay?: NotificationState): UserNotification {
if (!overlay) return notification;
return {
...notification,
status: overlay.status ?? notification.status,
read_at: overlay.read_at ?? notification.read_at ?? null,
dismissed_at: overlay.dismissed_at ?? notification.dismissed_at ?? null,
reply: overlay.reply ?? notification.reply ?? null,
replied_at: overlay.replied_at ?? notification.replied_at ?? null,
};
}
function dedupeNotifications(notifications: UserNotification[]): UserNotification[] {
const seen = new Set<string>();
return notifications.filter((notification) => {
if (!notification.notification_id || seen.has(notification.notification_id)) return false;
seen.add(notification.notification_id);
return true;
});
}
async function handleNotificationAction(
notificationId: string,
action: 'read' | 'dismiss' | 'reply',
body: Record<string, unknown>,
snapshot: QimingclawSnapshot | null,
): Promise<{ ok: boolean; task_id?: string; status?: string }> {
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
const current = buildNotifications(snapshot, state).find((notification) => notification.notification_id === notificationId);
const now = new Date().toISOString();
const overlay: NotificationState = { ...(state.notificationStateById?.[notificationId] ?? {}) };
if (action === 'read') {
overlay.status = current?.status === 'resolved' ? 'resolved' : 'read';
overlay.read_at = overlay.read_at ?? now;
} else if (action === 'dismiss') {
overlay.status = 'dismissed';
overlay.dismissed_at = now;
} else {
overlay.status = current?.status === 'resolved' ? 'resolved' : 'read';
overlay.read_at = overlay.read_at ?? now;
overlay.reply = stringValue(body.content ?? body.reply ?? body.message);
overlay.replied_at = now;
}
const nextState: AdapterState = {
...state,
notificationStateById: {
...(state.notificationStateById ?? {}),
[notificationId]: overlay,
},
};
await saveState(nextState, `notification_${action}`, undefined, { notification_id: notificationId, action });
return { ok: true, task_id: current?.task_id, status: overlay.status };
}
function buildWorkdayDecisions(
state: AdapterState,
date: string,

View File

@@ -1,7 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { Bell, Mail, X } from 'lucide-react';
import { Bell, Check, Mail, MessageSquare, X } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
import { RoleProvider } from '@/contexts/RoleContext';
import {
dismissNotification,
getNotifications,
getUnreadNotificationCount,
markNotificationRead,
replyToNotification,
type UserNotification,
} from '@/lib/api';
import CommandDeck from './CommandDeck';
import MissionCenter from './MissionCenter';
import SkillLibrary from './SkillLibrary';
@@ -68,15 +76,61 @@ function closeDigitalWindow() {
window.close();
}
function notificationLevelLabel(level: UserNotification['level']): string {
if (level === 'action') return '待处理';
if (level === 'error') return '异常';
if (level === 'warn') return '提醒';
return '消息';
}
function notificationStatusLabel(status: UserNotification['status']): string {
if (status === 'resolved') return '已解决';
if (status === 'dismissed') return '已忽略';
if (status === 'read') return '已读';
return '未读';
}
function notificationTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(date);
}
export default function DigitalEmployeeShell() {
const location = useLocation();
const navigate = useNavigate();
const [target, setTarget] = useState<DigitalNavigationTarget>(() => targetFromLocation(location.pathname, location.search));
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notifications, setNotifications] = useState<UserNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsLoading, setNotificationsLoading] = useState(false);
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
const refreshNotifications = useCallback(async () => {
setNotificationsLoading(true);
try {
const [items, count] = await Promise.all([
getNotifications('all'),
getUnreadNotificationCount(),
]);
setNotifications(items);
setUnreadCount(count);
} catch {
setNotifications([]);
setUnreadCount(0);
} finally {
setNotificationsLoading(false);
}
}, []);
useEffect(() => {
setTarget(targetFromLocation(location.pathname, location.search));
}, [location.pathname, location.search]);
useEffect(() => {
void refreshNotifications();
}, [refreshNotifications]);
const navigateDigital = useCallback((nextTarget: DigitalNavigationTarget) => {
setTarget(nextTarget);
const params = new URLSearchParams();
@@ -92,6 +146,24 @@ export default function DigitalEmployeeShell() {
}, { replace: false });
}, [location.pathname, navigate]);
const handleNotificationRead = useCallback(async (id: string) => {
await markNotificationRead(id);
await refreshNotifications();
}, [refreshNotifications]);
const handleNotificationDismiss = useCallback(async (id: string) => {
await dismissNotification(id);
await refreshNotifications();
}, [refreshNotifications]);
const handleNotificationReply = useCallback(async (id: string) => {
const content = (replyDraftById[id] ?? '').trim();
if (!content) return;
await replyToNotification(id, content);
setReplyDraftById((current) => ({ ...current, [id]: '' }));
await refreshNotifications();
}, [refreshNotifications, replyDraftById]);
return (
<RoleProvider>
<div className="de-shell">
@@ -118,9 +190,74 @@ export default function DigitalEmployeeShell() {
<button type="button" className="de-header-icon" title="消息">
<Mail size={16} />
</button>
<button type="button" className="de-header-icon" title="通知">
<Bell size={16} />
</button>
<div className="de-notification-anchor">
<button
type="button"
className={`de-header-icon ${notificationsOpen ? 'active' : ''}`}
title="通知"
onClick={() => {
setNotificationsOpen((open) => !open);
void refreshNotifications();
}}
>
<Bell size={16} />
{unreadCount > 0 && <span className="de-notification-count">{unreadCount > 99 ? '99+' : unreadCount}</span>}
</button>
{notificationsOpen && (
<div className="de-notification-popover">
<div className="de-notification-head">
<div>
<strong></strong>
<span>{unreadCount > 0 ? `${unreadCount} 条未读` : '暂无未读'}</span>
</div>
<button type="button" className="de-header-icon de-notification-close" title="关闭" onClick={() => setNotificationsOpen(false)}>
<X size={14} />
</button>
</div>
<div className="de-notification-list">
{notificationsLoading && <div className="de-notification-empty"></div>}
{!notificationsLoading && notifications.length === 0 && <div className="de-notification-empty"></div>}
{!notificationsLoading && notifications.map((notification) => (
<article key={notification.notification_id} className={`de-notification-item de-notification-item--${notification.level}`}>
<div className="de-notification-item-head">
<span className="de-notification-level">{notificationLevelLabel(notification.level)}</span>
<span className="de-notification-time">{notificationTime(notification.created_at)}</span>
</div>
<div className="de-notification-title-row">
<h3>{notification.title}</h3>
<span>{notificationStatusLabel(notification.status)}</span>
</div>
<p>{notification.body}</p>
{notification.reply && <div className="de-notification-reply">{notification.reply}</div>}
{notification.status !== 'resolved' && notification.status !== 'dismissed' && (
<div className="de-notification-actions">
{notification.status === 'unread' && (
<button type="button" className="de-notification-icon-btn" title="标记已读" onClick={() => void handleNotificationRead(notification.notification_id)}>
<Check size={14} />
</button>
)}
<button type="button" className="de-notification-icon-btn" title="忽略" onClick={() => void handleNotificationDismiss(notification.notification_id)}>
<X size={14} />
</button>
</div>
)}
{notification.kind === 'need_input' && notification.status !== 'dismissed' && (
<div className="de-notification-reply-row">
<input
value={replyDraftById[notification.notification_id] ?? ''}
onChange={(event) => setReplyDraftById((current) => ({ ...current, [notification.notification_id]: event.target.value }))}
/>
<button type="button" className="de-notification-icon-btn" title="回复" onClick={() => void handleNotificationReply(notification.notification_id)}>
<MessageSquare size={14} />
</button>
</div>
)}
</article>
))}
</div>
</div>
)}
</div>
<button type="button" className="de-header-icon de-header-close" title="关闭" onClick={closeDigitalWindow}>
<X size={17} />
</button>

View File

@@ -169,6 +169,8 @@ export interface UserNotification {
created_at: string;
read_at?: string | null;
dismissed_at?: string | null;
reply?: string | null;
replied_at?: string | null;
}
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';