吸收数字员工通知投影

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';

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,8 +16,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-g_4byzgP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKIQdvco.css">
<script type="module" crossorigin src="./assets/index-DvAXzZFK.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BarRiSjT.css">
</head>
<body>
<div id="root"></div>

View File

@@ -583,9 +583,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮先补 notification 投影和审批历史 UI再做多动作审批。
9. **通知 / Inbox / 用户消息**
- 已吸收:运行事件和同步失败能在数字员工页面展示。
- 缺口:sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply 尚未由 qimingclaw adapter 接管;没有统一数字员工 Inbox
- 建议:把 approval、同步失败、服务异常和任务完成统一投射为通知
- 已吸收:运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知通知投影已开始闭环
- 缺口:仍缺少系统级桌面通知、管理端通知视图、通知订阅策略、跨端未读同步和任务输入自动转化
- 建议:下一轮围绕通知订阅策略和管理端通知视图,把通知从本地 Inbox 投影推进到跨端协同入口
10. **入口路由 / 任务分流(路由决策事件闭环已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions` 已由 qimingclaw adapter 接管。