吸收数字员工通知订阅策略
This commit is contained in:
@@ -639,7 +639,7 @@
|
||||
width: min(380px, calc(100vw - 28px));
|
||||
max-height: min(620px, calc(100vh - 88px));
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: rgba(252,254,255,0.98);
|
||||
@@ -654,10 +654,56 @@
|
||||
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-title { 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-head-actions { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.de-notification-close { width: 26px; height: 26px; color: #496a85; background: rgba(79,172,254,0.1); }
|
||||
.de-notification-preferences {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 12px;
|
||||
border-bottom: 1px solid rgba(61,132,184,0.12);
|
||||
background: rgba(247,251,255,0.86);
|
||||
}
|
||||
.de-notification-switch,
|
||||
.de-notification-toggle-grid label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #31526d;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.de-notification-switch input,
|
||||
.de-notification-toggle-grid input { accent-color: #1684d8; }
|
||||
.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,
|
||||
.de-notification-toggle-grid { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.de-notification-segment-row button {
|
||||
min-height: 26px;
|
||||
border: 1px solid rgba(61,132,184,0.16);
|
||||
border-radius: 999px;
|
||||
padding: 0 10px;
|
||||
color: #31526d;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.de-notification-segment-row button.active {
|
||||
color: #fff;
|
||||
border-color: rgba(49,82,109,0.12);
|
||||
background: #31526d;
|
||||
}
|
||||
.de-notification-toggle-grid label {
|
||||
min-height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(61,132,184,0.12);
|
||||
}
|
||||
.de-notification-list {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Session,
|
||||
ChannelDetail,
|
||||
BrowserSession,
|
||||
NotificationPreferences,
|
||||
UserNotification,
|
||||
DebugStoreResponse,
|
||||
DebugPlan,
|
||||
@@ -542,7 +543,7 @@ export function getEventLog(params: {
|
||||
// Notifications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { type UserNotification };
|
||||
export { type NotificationPreferences, type UserNotification };
|
||||
|
||||
export function getNotifications(
|
||||
status: string = 'unread',
|
||||
@@ -558,6 +559,19 @@ export function getUnreadNotificationCount(): Promise<number> {
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotificationPreferences(): Promise<NotificationPreferences> {
|
||||
return apiFetch<NotificationPreferences>('/api/notifications/preferences');
|
||||
}
|
||||
|
||||
export function patchNotificationPreferences(
|
||||
patch: Partial<NotificationPreferences>,
|
||||
): Promise<NotificationPreferences> {
|
||||
return apiFetch<NotificationPreferences>('/api/notifications/preferences', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string): Promise<boolean> {
|
||||
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
IngressRouteRequest,
|
||||
IngressRouteResponse,
|
||||
MemoryEntry,
|
||||
NotificationPreferences,
|
||||
PlanDispatchResponse,
|
||||
PlanStepSummary,
|
||||
RouteDecisionRecord,
|
||||
@@ -85,6 +86,7 @@ interface AdapterState {
|
||||
reportGeneratedAtByDate: Record<string, string>;
|
||||
decisionResultsByDate: Record<string, Record<string, string>>;
|
||||
notificationStateById?: Record<string, NotificationState>;
|
||||
notificationPreferences?: NotificationPreferences;
|
||||
consoleRole?: string;
|
||||
rolePreferences?: Record<string, unknown>;
|
||||
profile: {
|
||||
@@ -103,6 +105,18 @@ interface NotificationState {
|
||||
replied_at?: string | null;
|
||||
}
|
||||
|
||||
const NOTIFICATION_LEVELS: UserNotification['level'][] = ['info', 'warn', 'error', 'action'];
|
||||
const NOTIFICATION_KINDS: UserNotification['kind'][] = [
|
||||
'user_message',
|
||||
'need_input',
|
||||
'need_approval',
|
||||
'task_progress',
|
||||
'task_failed',
|
||||
'task_finished',
|
||||
'confirmation_pending',
|
||||
'confirmation_resolved',
|
||||
];
|
||||
|
||||
interface QimingclawServiceStatus {
|
||||
running?: boolean;
|
||||
error?: string;
|
||||
@@ -836,6 +850,15 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
if (method === 'GET' && pathname === '/api/notifications') {
|
||||
return buildNotificationsResponse(url.searchParams.get('status'), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/notifications/preferences') {
|
||||
return notificationPreferences(notificationAdapterState(await readQimingclawSnapshot())) as T;
|
||||
}
|
||||
if (method === 'PATCH' && pathname === '/api/notifications/preferences') {
|
||||
return await handleNotificationPreferencesPatch(
|
||||
parseJsonBody<Partial<NotificationPreferences>>(options.body),
|
||||
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;
|
||||
@@ -922,6 +945,7 @@ function defaultState(): AdapterState {
|
||||
reportGeneratedAtByDate: {},
|
||||
decisionResultsByDate: {},
|
||||
notificationStateById: {},
|
||||
notificationPreferences: defaultNotificationPreferences(),
|
||||
consoleRole: 'sales',
|
||||
rolePreferences: {},
|
||||
profile: {
|
||||
@@ -936,12 +960,20 @@ function defaultState(): AdapterState {
|
||||
function readState(): AdapterState {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STATE_KEY);
|
||||
return raw ? { ...defaultState(), ...JSON.parse(raw) as Partial<AdapterState> } : defaultState();
|
||||
return normalizeAdapterState(raw ? JSON.parse(raw) as Partial<AdapterState> : {});
|
||||
} catch {
|
||||
return defaultState();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAdapterState(value: Partial<AdapterState>): AdapterState {
|
||||
return {
|
||||
...defaultState(),
|
||||
...value,
|
||||
notificationPreferences: notificationPreferences(value),
|
||||
};
|
||||
}
|
||||
|
||||
function writeState(state: AdapterState): void {
|
||||
window.localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
@@ -3423,7 +3455,7 @@ function dedupeApprovalTimelineEvents(events: ApprovalTimelineResponse['events']
|
||||
}
|
||||
|
||||
function notificationAdapterState(snapshot: QimingclawSnapshot | null): AdapterState {
|
||||
return snapshot?.uiState ?? readState();
|
||||
return snapshot?.uiState ? normalizeAdapterState(snapshot.uiState) : readState();
|
||||
}
|
||||
|
||||
function buildNotificationsResponse(status: string | null, snapshot: QimingclawSnapshot | null): { notifications: UserNotification[] } {
|
||||
@@ -3450,9 +3482,72 @@ function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterS
|
||||
const overlays = state.notificationStateById ?? {};
|
||||
return notifications
|
||||
.map((notification) => applyNotificationOverlay(notification, overlays[notification.notification_id]))
|
||||
.filter((notification) => notificationMatchesPreferences(notification, state.notificationPreferences))
|
||||
.sort((left, right) => right.created_at.localeCompare(left.created_at) || right.notification_id.localeCompare(left.notification_id));
|
||||
}
|
||||
|
||||
function defaultNotificationPreferences(): NotificationPreferences {
|
||||
return {
|
||||
enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
function notificationPreferences(state: Partial<AdapterState> | null | undefined): NotificationPreferences {
|
||||
const stored = state?.notificationPreferences;
|
||||
const fallback = defaultNotificationPreferences();
|
||||
if (!stored || typeof stored !== 'object') return fallback;
|
||||
const mutedKinds = Array.isArray(stored.muted_kinds)
|
||||
? stored.muted_kinds.filter((kind): kind is UserNotification['kind'] => NOTIFICATION_KINDS.includes(kind as UserNotification['kind']))
|
||||
: [];
|
||||
const mutedLevels = Array.isArray(stored.muted_levels)
|
||||
? stored.muted_levels.filter((level): level is UserNotification['level'] => NOTIFICATION_LEVELS.includes(level as UserNotification['level']))
|
||||
: [];
|
||||
return {
|
||||
enabled: stored.enabled !== false,
|
||||
muted_kinds: [...new Set(mutedKinds)],
|
||||
muted_levels: [...new Set(mutedLevels)],
|
||||
updated_at: typeof stored.updated_at === 'string' ? stored.updated_at : fallback.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function notificationMatchesPreferences(
|
||||
notification: UserNotification,
|
||||
preferences: NotificationPreferences | undefined,
|
||||
): boolean {
|
||||
const normalized = notificationPreferences({ notificationPreferences: preferences });
|
||||
if (!normalized.enabled) return false;
|
||||
if (normalized.muted_levels.includes(notification.level)) return false;
|
||||
return !normalized.muted_kinds.includes(notification.kind);
|
||||
}
|
||||
|
||||
async function handleNotificationPreferencesPatch(
|
||||
patch: Partial<NotificationPreferences>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<NotificationPreferences> {
|
||||
const state = snapshot?.uiState ?? await readBridgeUiState() ?? readState();
|
||||
const current = notificationPreferences(state);
|
||||
const next = notificationPreferences({
|
||||
notificationPreferences: {
|
||||
...current,
|
||||
...patch,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
await saveState(
|
||||
{
|
||||
...normalizeAdapterState(state),
|
||||
notificationPreferences: next,
|
||||
},
|
||||
'update_notification_preferences',
|
||||
undefined,
|
||||
{ notification_preferences: next },
|
||||
);
|
||||
return next;
|
||||
}
|
||||
|
||||
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
|
||||
return buildApprovals(snapshot)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Bell, Check, Mail, MessageSquare, X } from 'lucide-react';
|
||||
import { Bell, Check, Mail, MessageSquare, Settings2, X } from 'lucide-react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { RoleProvider } from '@/contexts/RoleContext';
|
||||
import {
|
||||
dismissNotification,
|
||||
getNotificationPreferences,
|
||||
getNotifications,
|
||||
getUnreadNotificationCount,
|
||||
markNotificationRead,
|
||||
patchNotificationPreferences,
|
||||
replyToNotification,
|
||||
type NotificationPreferences,
|
||||
type UserNotification,
|
||||
} from '@/lib/api';
|
||||
import CommandDeck from './CommandDeck';
|
||||
@@ -26,6 +29,28 @@ const TABS: { id: DigitalTab; label: string }[] = [
|
||||
];
|
||||
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'qimingclaw:digital-close';
|
||||
|
||||
const NOTIFICATION_LEVEL_OPTIONS: Array<{ id: UserNotification['level']; label: string }> = [
|
||||
{ id: 'info', label: '消息' },
|
||||
{ id: 'warn', label: '提醒' },
|
||||
{ id: 'error', label: '异常' },
|
||||
{ id: 'action', label: '待处理' },
|
||||
];
|
||||
|
||||
const NOTIFICATION_KIND_OPTIONS: Array<{ id: UserNotification['kind']; label: string }> = [
|
||||
{ id: 'task_finished', label: '任务完成' },
|
||||
{ id: 'task_failed', label: '任务异常' },
|
||||
{ id: 'need_approval', label: '审批' },
|
||||
{ id: 'need_input', label: '输入' },
|
||||
{ id: 'task_progress', label: '进度' },
|
||||
];
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
|
||||
enabled: true,
|
||||
muted_kinds: [],
|
||||
muted_levels: [],
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
function isDigitalTab(value: string | null): value is DigitalTab {
|
||||
return Boolean(value && TABS.some(tab => tab.id === value));
|
||||
}
|
||||
@@ -104,6 +129,8 @@ export default function DigitalEmployeeShell() {
|
||||
const [notifications, setNotifications] = useState<UserNotification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notificationsLoading, setNotificationsLoading] = useState(false);
|
||||
const [notificationSettingsOpen, setNotificationSettingsOpen] = useState(false);
|
||||
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferences>(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
const [replyDraftById, setReplyDraftById] = useState<Record<string, string>>({});
|
||||
|
||||
const refreshNotifications = useCallback(async () => {
|
||||
@@ -115,9 +142,13 @@ export default function DigitalEmployeeShell() {
|
||||
]);
|
||||
setNotifications(items);
|
||||
setUnreadCount(count);
|
||||
void getNotificationPreferences()
|
||||
.then(setNotificationPreferences)
|
||||
.catch(() => setNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES));
|
||||
} catch {
|
||||
setNotifications([]);
|
||||
setUnreadCount(0);
|
||||
setNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
} finally {
|
||||
setNotificationsLoading(false);
|
||||
}
|
||||
@@ -164,6 +195,26 @@ export default function DigitalEmployeeShell() {
|
||||
await refreshNotifications();
|
||||
}, [refreshNotifications, replyDraftById]);
|
||||
|
||||
const updateNotificationPreferences = useCallback(async (patch: Partial<NotificationPreferences>) => {
|
||||
const next = await patchNotificationPreferences(patch);
|
||||
setNotificationPreferences(next);
|
||||
await refreshNotifications();
|
||||
}, [refreshNotifications]);
|
||||
|
||||
const toggleMutedLevel = useCallback((level: UserNotification['level']) => {
|
||||
const muted = new Set(notificationPreferences.muted_levels);
|
||||
if (muted.has(level)) muted.delete(level);
|
||||
else muted.add(level);
|
||||
void updateNotificationPreferences({ muted_levels: Array.from(muted) });
|
||||
}, [notificationPreferences.muted_levels, updateNotificationPreferences]);
|
||||
|
||||
const toggleMutedKind = useCallback((kind: UserNotification['kind']) => {
|
||||
const muted = new Set(notificationPreferences.muted_kinds);
|
||||
if (muted.has(kind)) muted.delete(kind);
|
||||
else muted.add(kind);
|
||||
void updateNotificationPreferences({ muted_kinds: Array.from(muted) });
|
||||
}, [notificationPreferences.muted_kinds, updateNotificationPreferences]);
|
||||
|
||||
return (
|
||||
<RoleProvider>
|
||||
<div className="de-shell">
|
||||
@@ -206,14 +257,67 @@ export default function DigitalEmployeeShell() {
|
||||
{notificationsOpen && (
|
||||
<div className="de-notification-popover">
|
||||
<div className="de-notification-head">
|
||||
<div>
|
||||
<div className="de-notification-head-title">
|
||||
<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 className="de-notification-head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`de-header-icon de-notification-close ${notificationSettingsOpen ? 'active' : ''}`}
|
||||
title="通知设置"
|
||||
onClick={() => setNotificationSettingsOpen((open) => !open)}
|
||||
>
|
||||
<Settings2 size={14} />
|
||||
</button>
|
||||
<button type="button" className="de-header-icon de-notification-close" title="关闭" onClick={() => setNotificationsOpen(false)}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{notificationSettingsOpen && (
|
||||
<div className="de-notification-preferences">
|
||||
<label className="de-notification-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.enabled}
|
||||
onChange={(event) => { void updateNotificationPreferences({ enabled: event.target.checked }); }}
|
||||
/>
|
||||
<span>接收通知</span>
|
||||
</label>
|
||||
<div className="de-notification-preference-group">
|
||||
<span>静音等级</span>
|
||||
<div className="de-notification-segment-row">
|
||||
{NOTIFICATION_LEVEL_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={notificationPreferences.muted_levels.includes(option.id) ? 'active' : ''}
|
||||
title={`${option.label}静音`}
|
||||
onClick={() => toggleMutedLevel(option.id)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="de-notification-preference-group">
|
||||
<span>静音类型</span>
|
||||
<div className="de-notification-toggle-grid">
|
||||
{NOTIFICATION_KIND_OPTIONS.map((option) => (
|
||||
<label key={option.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationPreferences.muted_kinds.includes(option.id)}
|
||||
onChange={() => toggleMutedKind(option.id)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="de-notification-list">
|
||||
{notificationsLoading && <div className="de-notification-empty">加载中</div>}
|
||||
{!notificationsLoading && notifications.length === 0 && <div className="de-notification-empty">暂无通知</div>}
|
||||
|
||||
@@ -180,6 +180,13 @@ export interface UserNotification {
|
||||
replied_at?: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
enabled: boolean;
|
||||
muted_kinds: UserNotification['kind'][];
|
||||
muted_levels: UserNotification['level'][];
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
|
||||
|
||||
export interface WsMessage {
|
||||
|
||||
Reference in New Issue
Block a user