吸收数字员工通知订阅策略

This commit is contained in:
baiyanyun
2026-06-10 15:56:34 +08:00
parent 13e48df63d
commit 5b2f14123e
14 changed files with 597 additions and 195 deletions

View File

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

View File

@@ -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',

View File

@@ -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)

View File

@@ -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>}

View File

@@ -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 {

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-Cel_CqfI.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DLC8VZMk.css">
<script type="module" crossorigin src="./assets/index-DDRkk0GO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DqdYYbRq.css">
</head>
<body>
<div id="root"></div>

View File

@@ -195,6 +195,39 @@ function checkDigitalDiagnosticAuditProjection() {
console.log("[DigitalEmployeeCheck] diagnostic/audit projection hooks OK");
}
function checkDigitalNotificationPreferences() {
console.log("\n[DigitalEmployeeCheck] Verify notification preference hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const shellPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DigitalEmployeeShell.tsx");
const stateServicePath = path.join(packageRoot, "src", "main", "services", "digitalEmployee", "stateService.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const shell = fs.readFileSync(shellPath, "utf8");
const stateService = fs.readFileSync(stateServicePath, "utf8");
const requiredSnippets = [
[adapter, "/api/notifications/preferences", "adapter notification preference endpoint"],
[adapter, "handleNotificationPreferencesPatch", "adapter notification preference patch handler"],
[adapter, "notificationMatchesPreferences", "adapter notification preference filter"],
[adapter, "update_notification_preferences", "adapter notification preference save action"],
[api, "getNotificationPreferences", "notification preferences API getter"],
[api, "patchNotificationPreferences", "notification preferences API patcher"],
[types, "NotificationPreferences", "notification preferences type"],
[shell, "de-notification-preferences", "notification preferences UI"],
[shell, "NOTIFICATION_KIND_OPTIONS", "notification kind mute UI options"],
[stateService, "update_notification_preferences", "notification preference runtime event"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing notification preference hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] notification preference hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -238,6 +271,7 @@ function main() {
runStep("Build embedded digital employee", "npm", ["run", "build:sgrobot-digital"]);
checkSgrobotDigitalAssets();
checkDigitalDiagnosticAuditProjection();
checkDigitalNotificationPreferences();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -823,6 +823,62 @@ describe("digital employee state service", () => {
});
});
it("persists notification preferences and keeps notification overlays in UI state", async () => {
const { readDigitalEmployeeUiState, saveDigitalEmployeeUiState } = await import("./stateService");
saveDigitalEmployeeUiState({
action: "update_notification_preferences",
metadata: { source: "notification-popover" },
state: {
selectedDutyIdsByDate: {},
confirmedDates: {},
reportScheduleTime: "18:00",
reportGeneratedAtByDate: {},
decisionResultsByDate: {},
notificationStateById: {
"task:task-1": { status: "read", read_at: "2026-06-07T07:00:00.000Z" },
},
notificationPreferences: {
enabled: true,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
updated_at: "2026-06-07T08:00:00.000Z",
},
consoleRole: "sales",
rolePreferences: {},
profile: {
name: "飞天数字员工",
company: "qimingclaw 客户端",
position: "客户端任务执行员",
currentStatus: "working",
},
},
});
expect(readDigitalEmployeeUiState()).toMatchObject({
notificationStateById: {
"task:task-1": { status: "read", read_at: "2026-06-07T07:00:00.000Z" },
},
notificationPreferences: {
enabled: true,
muted_kinds: ["task_finished", "need_input"],
muted_levels: ["info"],
updated_at: "2026-06-07T08:00:00.000Z",
},
});
const preferenceEvent = Array.from(mockState.db?.events.values() ?? [])
.find((event) => event.kind === "digital_workday_update_notification_preferences");
expect(preferenceEvent).toMatchObject({
kind: "digital_workday_update_notification_preferences",
message: "数字员工通知订阅已更新",
});
expect(mockState.db?.outbox.get(`event:insert:${preferenceEvent?.id}`)).toMatchObject({
entity_type: "event",
operation: "insert",
status: "pending",
});
});
it("records governance commands as formal runtime records", async () => {
const { recordDigitalEmployeeGovernanceCommand } = await import("./stateService");

View File

@@ -466,6 +466,13 @@ export interface DigitalEmployeeUiState {
reportScheduleTime: string;
reportGeneratedAtByDate: Record<string, string>;
decisionResultsByDate: Record<string, Record<string, string>>;
notificationStateById?: Record<string, unknown>;
notificationPreferences?: {
enabled: boolean;
muted_kinds: string[];
muted_levels: string[];
updated_at: string | null;
};
consoleRole?: string;
rolePreferences?: Record<string, unknown>;
profile: {
@@ -640,6 +647,8 @@ function defaultUiState(): DigitalEmployeeUiState {
reportScheduleTime: "18:00",
reportGeneratedAtByDate: {},
decisionResultsByDate: {},
notificationStateById: {},
notificationPreferences: defaultNotificationPreferences(),
consoleRole: "sales",
rolePreferences: {},
profile: {
@@ -693,6 +702,34 @@ function normalizeRecord(value: unknown): Record<string, unknown> {
return { ...value as Record<string, unknown> };
}
function defaultNotificationPreferences(): NonNullable<DigitalEmployeeUiState["notificationPreferences"]> {
return {
enabled: true,
muted_kinds: [],
muted_levels: [],
updated_at: null,
};
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item): item is string => typeof item === "string" && Boolean(item.trim())))];
}
function normalizeNotificationPreferences(
value: unknown,
): NonNullable<DigitalEmployeeUiState["notificationPreferences"]> {
const record = value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
return {
enabled: record.enabled !== false,
muted_kinds: normalizeStringArray(record.muted_kinds),
muted_levels: normalizeStringArray(record.muted_levels),
updated_at: typeof record.updated_at === "string" ? record.updated_at : null,
};
}
function normalizeUiState(
value: Partial<DigitalEmployeeUiState>,
): DigitalEmployeeUiState {
@@ -706,6 +743,8 @@ function normalizeUiState(
: fallback.reportScheduleTime,
reportGeneratedAtByDate: normalizeStringMap(value.reportGeneratedAtByDate),
decisionResultsByDate: normalizeNestedStringMap(value.decisionResultsByDate),
notificationStateById: normalizeRecord(value.notificationStateById),
notificationPreferences: normalizeNotificationPreferences(value.notificationPreferences),
consoleRole: typeof value.consoleRole === "string" && value.consoleRole.trim()
? value.consoleRole
: fallback.consoleRole,
@@ -744,6 +783,8 @@ function digitalEmployeeUiActionMessage(action: string, date?: string): string {
return "数字员工角色已更新";
case "update_role_preferences":
return "数字员工运营偏好已更新";
case "update_notification_preferences":
return "数字员工通知订阅已更新";
default:
return `数字员工页面状态已更新${suffix}`;
}

View File

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