吸收数字员工通知投影
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user