@qiqb/message-domain 0.0.0-stage → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -2
- package/build/components/DefaultMessageReminderRenderer.d.ts +10 -0
- package/build/components/DefaultMessageReminderRenderer.js +29 -0
- package/build/components/MessageNavigation.d.ts +7 -0
- package/build/components/MessageNavigation.js +192 -0
- package/build/components/MessageReminderDisplay.d.ts +21 -0
- package/build/components/MessageReminderDisplay.js +353 -0
- package/build/components/MessageWorkspace.d.ts +16 -0
- package/build/components/MessageWorkspace.js +26 -0
- package/build/components/message-navigation.less +89 -0
- package/build/components/message-reminder-display.less +367 -0
- package/build/components/message-workspace.less +150 -0
- package/build/index.d.ts +20 -0
- package/build/index.js +17 -0
- package/build/pages/inbox/InboxPage.d.ts +2 -0
- package/build/pages/inbox/InboxPage.js +265 -0
- package/build/pages/inbox/inbox.less +430 -0
- package/build/pages/subscription/PersonalSubscriptionPage.d.ts +1 -0
- package/build/pages/subscription/PersonalSubscriptionPage.js +6 -0
- package/build/pages/subscription/RoleSubscriptionPage.d.ts +1 -0
- package/build/pages/subscription/RoleSubscriptionPage.js +5 -0
- package/build/pages/subscription/SubscriptionForm.d.ts +16 -0
- package/build/pages/subscription/SubscriptionForm.js +111 -0
- package/build/pages/subscription/SubscriptionPage.d.ts +5 -0
- package/build/pages/subscription/SubscriptionPage.js +273 -0
- package/build/pages/subscription/subscription-form.less +341 -0
- package/build/pages/subscription/subscription.less +250 -0
- package/build/pages/topic/TopicPage.d.ts +1 -0
- package/build/pages/topic/TopicPage.js +291 -0
- package/build/routes.cjs +38 -0
- package/build/routes.d.cts +22 -0
- package/build/routes.d.ts +22 -0
- package/build/routes.js +34 -0
- package/build/runtime/MessageReminderLayout.d.ts +14 -0
- package/build/runtime/MessageReminderLayout.js +25 -0
- package/build/runtime/MessageReminders.d.ts +16 -0
- package/build/runtime/MessageReminders.js +364 -0
- package/build/runtime/index.d.ts +79 -0
- package/build/runtime/index.js +105 -0
- package/build/runtime/message-reminder-layout.less +40 -0
- package/build/runtime/messageRealtime.d.ts +15 -0
- package/build/runtime/messageRealtime.js +99 -0
- package/build/runtime/reminderEvents.d.ts +18 -0
- package/build/runtime/reminderEvents.js +53 -0
- package/build/runtime/reminderOwnership.d.ts +35 -0
- package/build/runtime/reminderOwnership.js +135 -0
- package/build/runtime/reminderPresentation.d.ts +34 -0
- package/build/runtime/reminderPresentation.js +57 -0
- package/build/runtime/reminderQuery.d.ts +18 -0
- package/build/runtime/reminderQuery.js +33 -0
- package/build/runtime/reminderQueue.d.ts +25 -0
- package/build/runtime/reminderQueue.js +88 -0
- package/build/services/MessageController.d.ts +108 -0
- package/build/services/MessageController.js +42 -0
- package/build/types/index.d.ts +12 -0
- package/build/types/index.js +1 -0
- package/package.json +96 -4
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { theme } from 'antd';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { MessageReminderPresentation } from '../components/DefaultMessageReminderRenderer.js';
|
|
5
|
+
import { createReminderPresentationActions } from './reminderPresentation.js';
|
|
6
|
+
import { getMessageReminders, markInboxMessageRead, } from '../services/MessageController.js';
|
|
7
|
+
import { getMessageDomainRuntime, openMessageInbox } from './index.js';
|
|
8
|
+
import { startMessageRealtimeRefresh } from './messageRealtime.js';
|
|
9
|
+
import { scanMessageReminderPages } from './reminderQuery.js';
|
|
10
|
+
import { useMessageReminderTarget } from './MessageReminderLayout.js';
|
|
11
|
+
import { dispatchMessageReceiptRead, subscribeMessageReceiptRead, subscribeMessageReminderPreview, } from './reminderEvents.js';
|
|
12
|
+
import { acquireMessageReminderOwnership, } from './reminderOwnership.js';
|
|
13
|
+
import { ReminderQueue, isReminderPreview, reminderReceiptId, } from './reminderQueue.js';
|
|
14
|
+
const storageKey = (accountId, namespace) => `${namespace}:reminders:${accountId}`;
|
|
15
|
+
function restoreRemembered(accountId, namespace) {
|
|
16
|
+
if (!accountId)
|
|
17
|
+
return [];
|
|
18
|
+
try {
|
|
19
|
+
const value = JSON.parse(sessionStorage.getItem(storageKey(accountId, namespace)) || '[]');
|
|
20
|
+
return Array.isArray(value)
|
|
21
|
+
? value
|
|
22
|
+
.filter((item) => typeof item === 'string')
|
|
23
|
+
.slice(-5000)
|
|
24
|
+
: [];
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// 浏览器禁止存储或旧值损坏时使用内存去重,不影响正式消息服务。
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 请求、账号和导航由宿主提供;当前文档通过跨 bundle owner 选举避免重复正式提醒。
|
|
33
|
+
* namespace 内的预览单独保存,非 owner 也可预览,且预览不会进入正式队列或调用阅读接口。
|
|
34
|
+
*/
|
|
35
|
+
export default function MessageReminders({ accountId, onOpen, priority = 0, renderer, }) {
|
|
36
|
+
const { token } = theme.useToken();
|
|
37
|
+
const { storageNamespace: namespace, apiPrefix, realtime, } = getMessageDomainRuntime();
|
|
38
|
+
const sourceKey = JSON.stringify([apiPrefix, accountId]);
|
|
39
|
+
const previewKey = JSON.stringify([sourceKey, namespace]);
|
|
40
|
+
const currentSourceKey = useRef(sourceKey);
|
|
41
|
+
currentSourceKey.current = sourceKey;
|
|
42
|
+
const currentPreviewKey = useRef(previewKey);
|
|
43
|
+
currentPreviewKey.current = previewKey;
|
|
44
|
+
const leaseSourceKey = useRef();
|
|
45
|
+
const previewSourceKey = useRef();
|
|
46
|
+
const carouselTarget = useMessageReminderTarget();
|
|
47
|
+
const [previewTarget, setPreviewTarget] = useState();
|
|
48
|
+
const [previewRecord, setPreviewRecord] = useState();
|
|
49
|
+
const currentPreviewRecord = useRef();
|
|
50
|
+
const mounted = useRef(true);
|
|
51
|
+
const [owner, setOwner] = useState(false);
|
|
52
|
+
const leaseRef = useRef();
|
|
53
|
+
const readHandler = useRef(() => undefined);
|
|
54
|
+
const queue = useRef(new ReminderQueue());
|
|
55
|
+
const generation = useRef(0);
|
|
56
|
+
const retry = useRef(() => undefined);
|
|
57
|
+
const confirmations = useRef(new Map());
|
|
58
|
+
const [records, setRecords] = useState([]);
|
|
59
|
+
const [confirmingIds, setConfirmingIds] = useState([]);
|
|
60
|
+
const [confirmError, setConfirmError] = useState();
|
|
61
|
+
const [connectionFailed, setConnectionFailed] = useState(false);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
mounted.current = true;
|
|
64
|
+
return () => {
|
|
65
|
+
// 项目展示可能保留离场动画回调;卸载时让预览与状态回调一并失效。
|
|
66
|
+
mounted.current = false;
|
|
67
|
+
currentPreviewRecord.current = undefined;
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const target = document.createElement('div');
|
|
72
|
+
target.className = 'qiqb-message-reminder-preview-slot';
|
|
73
|
+
target.style.zIndex = String(token.zIndexPopupBase + 20);
|
|
74
|
+
document.body.appendChild(target);
|
|
75
|
+
setPreviewTarget(target);
|
|
76
|
+
return () => target.remove();
|
|
77
|
+
}, [token.zIndexPopupBase]);
|
|
78
|
+
const persistRemembered = useCallback(() => {
|
|
79
|
+
const lease = leaseRef.current;
|
|
80
|
+
if (!accountId || leaseSourceKey.current !== sourceKey || !lease?.isOwner())
|
|
81
|
+
return;
|
|
82
|
+
const ids = queue.current.remembered();
|
|
83
|
+
// owner 交接只传递已展示/明确关闭的收件标识,不共享正文或账号凭证。
|
|
84
|
+
lease.remember(ids);
|
|
85
|
+
try {
|
|
86
|
+
sessionStorage.setItem(storageKey(accountId, namespace), JSON.stringify(ids));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// 存储失败仍保留当前文档内存去重,阅读状态以服务端为准。
|
|
90
|
+
}
|
|
91
|
+
}, [accountId, namespace, sourceKey]);
|
|
92
|
+
const refreshRecords = useCallback(() => setRecords(queue.current.messages()), []);
|
|
93
|
+
readHandler.current = (id) => {
|
|
94
|
+
queue.current.dismiss(id);
|
|
95
|
+
persistRemembered();
|
|
96
|
+
refreshRecords();
|
|
97
|
+
};
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
let active = true;
|
|
100
|
+
if (!accountId) {
|
|
101
|
+
leaseSourceKey.current = undefined;
|
|
102
|
+
setOwner(false);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const lease = acquireMessageReminderOwnership({
|
|
106
|
+
apiPrefix,
|
|
107
|
+
accountId,
|
|
108
|
+
priority,
|
|
109
|
+
onOwnerChange: (value) => {
|
|
110
|
+
if (active)
|
|
111
|
+
setOwner(value);
|
|
112
|
+
},
|
|
113
|
+
onRead: (id, readAt) => {
|
|
114
|
+
if (active)
|
|
115
|
+
readHandler.current(id, readAt);
|
|
116
|
+
},
|
|
117
|
+
onRetry: () => {
|
|
118
|
+
// 铃铛可与 owner 位于不同 bundle;只允许当前账户的正式轮询被唤醒。
|
|
119
|
+
if (active && currentSourceKey.current === sourceKey)
|
|
120
|
+
retry.current();
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
leaseRef.current = lease;
|
|
124
|
+
leaseSourceKey.current = sourceKey;
|
|
125
|
+
return () => {
|
|
126
|
+
active = false;
|
|
127
|
+
if (leaseRef.current === lease) {
|
|
128
|
+
leaseRef.current = undefined;
|
|
129
|
+
leaseSourceKey.current = undefined;
|
|
130
|
+
}
|
|
131
|
+
lease.release();
|
|
132
|
+
};
|
|
133
|
+
}, [accountId, apiPrefix, priority, sourceKey]);
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
setPreviewRecord(undefined);
|
|
136
|
+
}, [accountId, namespace]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
let cancelled = false;
|
|
139
|
+
let scanPage = 2;
|
|
140
|
+
++generation.current;
|
|
141
|
+
const lease = leaseRef.current;
|
|
142
|
+
queue.current = new ReminderQueue([
|
|
143
|
+
...restoreRemembered(accountId, namespace),
|
|
144
|
+
...(lease?.remembered() || []),
|
|
145
|
+
]);
|
|
146
|
+
confirmations.current.clear();
|
|
147
|
+
setRecords([]);
|
|
148
|
+
setConfirmingIds([]);
|
|
149
|
+
setConfirmError(undefined);
|
|
150
|
+
setConnectionFailed(false);
|
|
151
|
+
if (!owner || !accountId || !lease?.isOwner()) {
|
|
152
|
+
retry.current = () => undefined;
|
|
153
|
+
return () => {
|
|
154
|
+
cancelled = true;
|
|
155
|
+
++generation.current;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const currentOwner = () => !cancelled &&
|
|
159
|
+
currentSourceKey.current === sourceKey &&
|
|
160
|
+
lease.isOwner() &&
|
|
161
|
+
leaseRef.current === lease;
|
|
162
|
+
const poll = async (complete) => {
|
|
163
|
+
if (!currentOwner())
|
|
164
|
+
return;
|
|
165
|
+
try {
|
|
166
|
+
// 接入实时后首次/重连/变化与低频对账扫描全部分页,不能依赖高频轮转补齐历史记录。
|
|
167
|
+
const scanned = await scanMessageReminderPages({
|
|
168
|
+
complete,
|
|
169
|
+
scanPage,
|
|
170
|
+
isCurrent: currentOwner,
|
|
171
|
+
loadPage: async (current) => (await getMessageReminders({ current, pageSize: 100 })).data,
|
|
172
|
+
accept: (items) => {
|
|
173
|
+
queue.current.ingest(items);
|
|
174
|
+
refreshRecords();
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
if (!scanned || !currentOwner())
|
|
178
|
+
return;
|
|
179
|
+
scanPage = scanned.scanPage;
|
|
180
|
+
if (complete)
|
|
181
|
+
queue.current.retainUnread(scanned.unreadIds);
|
|
182
|
+
setConnectionFailed(false);
|
|
183
|
+
lease.reportConnectionFailed(false);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
if (currentOwner()) {
|
|
187
|
+
setConnectionFailed(true);
|
|
188
|
+
lease.reportConnectionFailed(true);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
finally {
|
|
192
|
+
if (currentOwner())
|
|
193
|
+
refreshRecords();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const loop = startMessageRealtimeRefresh({
|
|
197
|
+
realtime,
|
|
198
|
+
refresh: poll,
|
|
199
|
+
onChanged: (change) => {
|
|
200
|
+
if (!currentOwner())
|
|
201
|
+
return;
|
|
202
|
+
// 服务器首次已读事件才移除正式提醒,收到普通在线数据或传输 ACK 不等于阅读。
|
|
203
|
+
if (change?.receiptId && change.readAt)
|
|
204
|
+
lease.reportRead(change.receiptId, change.readAt);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
retry.current = loop.wake;
|
|
208
|
+
return () => {
|
|
209
|
+
cancelled = true;
|
|
210
|
+
++generation.current;
|
|
211
|
+
loop.release();
|
|
212
|
+
retry.current = () => undefined;
|
|
213
|
+
};
|
|
214
|
+
}, [
|
|
215
|
+
accountId,
|
|
216
|
+
namespace,
|
|
217
|
+
apiPrefix,
|
|
218
|
+
priority,
|
|
219
|
+
owner,
|
|
220
|
+
sourceKey,
|
|
221
|
+
refreshRecords,
|
|
222
|
+
realtime,
|
|
223
|
+
]);
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const unsubscribePreview = subscribeMessageReminderPreview((record) => {
|
|
226
|
+
if (!isReminderPreview(record))
|
|
227
|
+
return;
|
|
228
|
+
previewSourceKey.current = currentPreviewKey.current;
|
|
229
|
+
setPreviewRecord(record);
|
|
230
|
+
setConfirmError(undefined);
|
|
231
|
+
});
|
|
232
|
+
const unsubscribeRead = subscribeMessageReceiptRead(({ id, readAt }) => {
|
|
233
|
+
// 同 apiPrefix/账号的 owner 接收其他 namespace 已确认的阅读事实,避免保留旧全屏提醒。
|
|
234
|
+
if (leaseRef.current)
|
|
235
|
+
leaseRef.current.reportRead(id, readAt);
|
|
236
|
+
else
|
|
237
|
+
readHandler.current(id, readAt);
|
|
238
|
+
});
|
|
239
|
+
return () => {
|
|
240
|
+
unsubscribePreview();
|
|
241
|
+
unsubscribeRead();
|
|
242
|
+
};
|
|
243
|
+
}, [namespace]);
|
|
244
|
+
const dismiss = useCallback((id) => {
|
|
245
|
+
if (id.startsWith('preview:')) {
|
|
246
|
+
setPreviewRecord((current) => current && reminderReceiptId(current) === id ? undefined : current);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// 关闭正式提醒仅结束当前展示,收件箱未读状态不随关闭改变。
|
|
250
|
+
queue.current.dismiss(id);
|
|
251
|
+
persistRemembered();
|
|
252
|
+
refreshRecords();
|
|
253
|
+
}, [persistRemembered, refreshRecords]);
|
|
254
|
+
const displayed = useCallback((record) => {
|
|
255
|
+
if (leaseRef.current?.isOwner() && queue.current.presented(record))
|
|
256
|
+
persistRemembered();
|
|
257
|
+
}, [persistRemembered]);
|
|
258
|
+
const confirm = useCallback((record) => {
|
|
259
|
+
const id = reminderReceiptId(record);
|
|
260
|
+
if (isReminderPreview(record)) {
|
|
261
|
+
dismiss(id);
|
|
262
|
+
return Promise.resolve();
|
|
263
|
+
}
|
|
264
|
+
const lease = leaseRef.current;
|
|
265
|
+
if (!lease?.isOwner())
|
|
266
|
+
return Promise.reject(new Error('消息提醒已由其他入口接管'));
|
|
267
|
+
const pending = confirmations.current.get(id);
|
|
268
|
+
if (pending)
|
|
269
|
+
return pending;
|
|
270
|
+
const version = generation.current;
|
|
271
|
+
setConfirmError(undefined);
|
|
272
|
+
setConfirmingIds((current) => [...current, id]);
|
|
273
|
+
const operation = (async () => {
|
|
274
|
+
try {
|
|
275
|
+
// 仅明确点击确认/查看才标已读,浮动、轮播、全屏出现本身均不更改服务端状态。
|
|
276
|
+
await markInboxMessageRead(id);
|
|
277
|
+
const readAt = new Date().toISOString();
|
|
278
|
+
lease.reportRead(id, readAt);
|
|
279
|
+
if (version !== generation.current ||
|
|
280
|
+
currentSourceKey.current !== sourceKey ||
|
|
281
|
+
!lease.isOwner())
|
|
282
|
+
return;
|
|
283
|
+
dismiss(id);
|
|
284
|
+
dispatchMessageReceiptRead(id, readAt);
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (version === generation.current &&
|
|
288
|
+
currentSourceKey.current === sourceKey &&
|
|
289
|
+
lease.isOwner())
|
|
290
|
+
setConfirmError(error instanceof Error ? error.message : '确认失败,请重试');
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
if (version === generation.current) {
|
|
295
|
+
confirmations.current.delete(id);
|
|
296
|
+
setConfirmingIds((current) => current.filter((value) => value !== id));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
})();
|
|
300
|
+
confirmations.current.set(id, operation);
|
|
301
|
+
return operation;
|
|
302
|
+
}, [dismiss, sourceKey]);
|
|
303
|
+
const open = (record) => {
|
|
304
|
+
if (isReminderPreview(record)) {
|
|
305
|
+
dismiss(reminderReceiptId(record));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const version = generation.current;
|
|
309
|
+
void confirm(record)
|
|
310
|
+
.then(() => {
|
|
311
|
+
if (version === generation.current &&
|
|
312
|
+
currentSourceKey.current === sourceKey &&
|
|
313
|
+
leaseRef.current?.isOwner())
|
|
314
|
+
(onOpen || openMessageInbox)(record);
|
|
315
|
+
})
|
|
316
|
+
.catch(() => undefined);
|
|
317
|
+
};
|
|
318
|
+
// 只有实际 owner 的正式队列进入展示;本 namespace 预览独立于 owner 选举。
|
|
319
|
+
// 在退出/换账号的首个渲染便隐藏旧内容,不等待被动 effect 清理,避免新账号短暂看到旧消息。
|
|
320
|
+
const ownsFormal = owner &&
|
|
321
|
+
leaseSourceKey.current === sourceKey &&
|
|
322
|
+
!!leaseRef.current?.isOwner();
|
|
323
|
+
const formalRecords = ownsFormal ? records : [];
|
|
324
|
+
const activePreview = previewSourceKey.current === previewKey ? previewRecord : undefined;
|
|
325
|
+
currentPreviewRecord.current = activePreview;
|
|
326
|
+
const previewingCarousel = activePreview?.reminderMode === 'CAROUSEL';
|
|
327
|
+
const displayRecords = [
|
|
328
|
+
...(activePreview ? [activePreview] : []),
|
|
329
|
+
...formalRecords.filter((record) => !previewingCarousel || record.reminderMode !== 'CAROUSEL'),
|
|
330
|
+
];
|
|
331
|
+
// 展示层得到记录的浅拷贝;业务组件修改临时外观字段不会改动平台队列中的原始快照。
|
|
332
|
+
const displayMessages = displayRecords.map((record) => ({ ...record }));
|
|
333
|
+
const snapshot = {
|
|
334
|
+
sourceKey,
|
|
335
|
+
previewKey,
|
|
336
|
+
queue: queue.current,
|
|
337
|
+
lease: leaseRef.current,
|
|
338
|
+
preview: activePreview,
|
|
339
|
+
};
|
|
340
|
+
const presentationActions = createReminderPresentationActions(displayMessages, snapshot, () => ({
|
|
341
|
+
sourceKey: currentSourceKey.current,
|
|
342
|
+
previewKey: currentPreviewKey.current,
|
|
343
|
+
queue: queue.current,
|
|
344
|
+
lease: leaseRef.current,
|
|
345
|
+
preview: currentPreviewRecord.current,
|
|
346
|
+
}), {
|
|
347
|
+
onDismiss: dismiss,
|
|
348
|
+
onOpen: open,
|
|
349
|
+
onConfirm: confirm,
|
|
350
|
+
onDisplayed: displayed,
|
|
351
|
+
});
|
|
352
|
+
const samePresentation = () => mounted.current &&
|
|
353
|
+
currentSourceKey.current === sourceKey &&
|
|
354
|
+
currentPreviewKey.current === previewKey &&
|
|
355
|
+
queue.current === snapshot.queue &&
|
|
356
|
+
leaseRef.current === snapshot.lease;
|
|
357
|
+
return (_jsx(MessageReminderPresentation, { renderer: renderer, accountId: accountId, messages: displayMessages, carouselTarget: previewingCarousel ? previewTarget : carouselTarget, ...presentationActions, confirmingIds: ownsFormal ? confirmingIds : [], confirmError: ownsFormal ? confirmError : undefined, connectionFailed: !!accountId && ownsFormal && connectionFailed, onRetry: () => {
|
|
358
|
+
if (samePresentation() && snapshot.lease?.isOwner())
|
|
359
|
+
retry.current();
|
|
360
|
+
}, onClearError: () => {
|
|
361
|
+
if (samePresentation())
|
|
362
|
+
setConfirmError(undefined);
|
|
363
|
+
} }, previewKey));
|
|
364
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { InboxMessage } from '../services/MessageController.js';
|
|
2
|
+
import type { MessageReminderRenderer } from './reminderPresentation.js';
|
|
3
|
+
export interface MessageRequestOptions {
|
|
4
|
+
method?: string;
|
|
5
|
+
params?: unknown;
|
|
6
|
+
data?: unknown;
|
|
7
|
+
headers?: HeadersInit;
|
|
8
|
+
skipErrorHandler?: boolean;
|
|
9
|
+
[key: string]: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type MessageRequest = <T = unknown>(url: string, options?: MessageRequestOptions) => Promise<T>;
|
|
12
|
+
export interface MessageDomainAccess {
|
|
13
|
+
verifyElementPermission?: (code?: string, projectCode?: string) => boolean;
|
|
14
|
+
}
|
|
15
|
+
export type MessageRealtimeState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'disabled' | 'released';
|
|
16
|
+
/** 在线事件只提示收件事实变化,正文继续通过当前账号的 HTTP 接口查询。 */
|
|
17
|
+
export interface MessageInboxChange {
|
|
18
|
+
receiptId?: string;
|
|
19
|
+
readAt?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MessageRealtimeLease {
|
|
22
|
+
getState(): MessageRealtimeState;
|
|
23
|
+
onStateChange(listener: (state: MessageRealtimeState) => void, emitImmediately?: boolean): () => void;
|
|
24
|
+
onChanged(listener: (change: MessageInboxChange) => void): () => void;
|
|
25
|
+
release(): void;
|
|
26
|
+
}
|
|
27
|
+
export interface MessageRealtimeAdapter {
|
|
28
|
+
/** 宿主提供同账户共享的连接租约;消息包不直接依赖 Socket.IO 客户端。 */
|
|
29
|
+
acquire(): MessageRealtimeLease;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 消息领域与宿主之间的稳定边界。宿主在页面挂载前配置一次,控制器和页面随后读取同一实现。
|
|
33
|
+
* request 继续使用宿主 token/超时处理;包不引用 Umi、平台页面包或任一业务 shared 包。
|
|
34
|
+
*/
|
|
35
|
+
export interface MessageDomainRuntimeAdapter {
|
|
36
|
+
realtime?: MessageRealtimeAdapter;
|
|
37
|
+
request?: MessageRequest;
|
|
38
|
+
navigate?: (path: string) => void;
|
|
39
|
+
useLocation?: () => {
|
|
40
|
+
search: string;
|
|
41
|
+
};
|
|
42
|
+
useAccess?: () => MessageDomainAccess;
|
|
43
|
+
onOpen?: (record: InboxMessage) => void;
|
|
44
|
+
/** 未提供时使用平台默认外观;只替换展示,消息生命周期和数据隔离由平台继续维护。 */
|
|
45
|
+
reminderRenderer?: MessageReminderRenderer;
|
|
46
|
+
/** 字段名称属于发布方业务语义;宿主可提供工厂/订单等名称,平台默认只显示通用标签。 */
|
|
47
|
+
fieldLabel?: (field: string) => string | undefined;
|
|
48
|
+
apiPrefix?: string;
|
|
49
|
+
projectCode?: string;
|
|
50
|
+
/** 宿主创建消息主题的业务来源,与发布接口的 sourceSystem 一致;不由权限项目号推断。 */
|
|
51
|
+
sourceSystem?: string;
|
|
52
|
+
inboxPath?: string;
|
|
53
|
+
receiptQueryParam?: string;
|
|
54
|
+
/** BIP 传入 prokeeper-bip 可沿用原来的 sessionStorage 收件标识去重记录。 */
|
|
55
|
+
storageNamespace?: string;
|
|
56
|
+
/** 默认跟随 storageNamespace,隔离同一浏览器文档中不同业务域的预览事件。 */
|
|
57
|
+
eventNamespace?: string;
|
|
58
|
+
}
|
|
59
|
+
export type MessageDomainRuntime = MessageDomainRuntimeAdapter & {
|
|
60
|
+
apiPrefix: string;
|
|
61
|
+
projectCode: string;
|
|
62
|
+
inboxPath: string;
|
|
63
|
+
receiptQueryParam: string;
|
|
64
|
+
storageNamespace: string;
|
|
65
|
+
eventNamespace: string;
|
|
66
|
+
};
|
|
67
|
+
export declare function configureMessageDomainRuntime(adapter: MessageDomainRuntimeAdapter): void;
|
|
68
|
+
export declare const getMessageDomainRuntime: () => Readonly<MessageDomainRuntime>;
|
|
69
|
+
export declare const request: MessageRequest;
|
|
70
|
+
/** 缺少授权适配器时默认拒绝写操作;显式项目编码不跟随宿主当前菜单而漂移。 */
|
|
71
|
+
export declare function useAccess(): {
|
|
72
|
+
verifyElementPermission: (code?: string, projectCode?: string) => boolean;
|
|
73
|
+
};
|
|
74
|
+
export declare const useLocation: () => {
|
|
75
|
+
search: string;
|
|
76
|
+
};
|
|
77
|
+
export declare function navigate(path: string): void;
|
|
78
|
+
export declare function openMessageInbox(record: InboxMessage): void;
|
|
79
|
+
export declare function getMessageFieldLabel(field: string): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { MESSAGE_BASE_PATH } from '../routes.js';
|
|
3
|
+
let runtime = {
|
|
4
|
+
apiPrefix: '/api/qiqb_platform/message',
|
|
5
|
+
projectCode: 'qiqb_platform',
|
|
6
|
+
inboxPath: `${MESSAGE_BASE_PATH}/inbox`,
|
|
7
|
+
receiptQueryParam: 'receiptId',
|
|
8
|
+
storageNamespace: 'qiqb-platform',
|
|
9
|
+
eventNamespace: 'qiqb-platform',
|
|
10
|
+
};
|
|
11
|
+
export function configureMessageDomainRuntime(adapter) {
|
|
12
|
+
const next = { ...runtime, ...adapter };
|
|
13
|
+
// 来源编码保留大小写,后端会严格核对;显式清空时不沿用其它宿主的来源。
|
|
14
|
+
next.sourceSystem = next.sourceSystem?.trim() || undefined;
|
|
15
|
+
next.apiPrefix = (adapter.apiPrefix ?? runtime.apiPrefix).replace(/\/+$/, '');
|
|
16
|
+
next.projectCode = adapter.projectCode?.trim() || runtime.projectCode;
|
|
17
|
+
next.storageNamespace =
|
|
18
|
+
adapter.storageNamespace?.trim() || runtime.storageNamespace;
|
|
19
|
+
next.eventNamespace =
|
|
20
|
+
adapter.eventNamespace?.trim() ||
|
|
21
|
+
(adapter.storageNamespace !== undefined
|
|
22
|
+
? next.storageNamespace
|
|
23
|
+
: runtime.eventNamespace);
|
|
24
|
+
next.inboxPath = adapter.inboxPath || runtime.inboxPath;
|
|
25
|
+
next.receiptQueryParam =
|
|
26
|
+
adapter.receiptQueryParam?.trim() || runtime.receiptQueryParam;
|
|
27
|
+
runtime = next;
|
|
28
|
+
}
|
|
29
|
+
export const getMessageDomainRuntime = () => runtime;
|
|
30
|
+
/** fetch 仅用于无框架预览;登录鉴权、token 续期和错误策略应由正式宿主的 request 注入。 */
|
|
31
|
+
const fetchRequest = async (url, options = {}) => {
|
|
32
|
+
const { method = 'GET', params, data, headers, skipErrorHandler: _skip, ...rest } = options;
|
|
33
|
+
const search = new URLSearchParams();
|
|
34
|
+
if (params && typeof params === 'object') {
|
|
35
|
+
for (const [key, value] of Object.entries(params)) {
|
|
36
|
+
if (value === undefined || value === null)
|
|
37
|
+
continue;
|
|
38
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
39
|
+
search.append(key, String(item));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const query = search.toString();
|
|
43
|
+
const target = query ? `${url}${url.includes('?') ? '&' : '?'}${query}` : url;
|
|
44
|
+
const requestHeaders = new Headers(headers);
|
|
45
|
+
if (data !== undefined && !requestHeaders.has('Content-Type'))
|
|
46
|
+
requestHeaders.set('Content-Type', 'application/json');
|
|
47
|
+
const response = await fetch(target, {
|
|
48
|
+
credentials: 'same-origin',
|
|
49
|
+
...rest,
|
|
50
|
+
method,
|
|
51
|
+
headers: requestHeaders,
|
|
52
|
+
...(data !== undefined && method.toUpperCase() !== 'GET'
|
|
53
|
+
? { body: JSON.stringify(data) }
|
|
54
|
+
: {}),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok)
|
|
57
|
+
throw new Error((await response.text()) || `消息服务请求失败(${response.status})`);
|
|
58
|
+
return (response.status === 204 ? undefined : await response.json());
|
|
59
|
+
};
|
|
60
|
+
export const request = (url, options) => (runtime.request || fetchRequest)(url, options);
|
|
61
|
+
/** 缺少授权适配器时默认拒绝写操作;显式项目编码不跟随宿主当前菜单而漂移。 */
|
|
62
|
+
export function useAccess() {
|
|
63
|
+
const access = runtime.useAccess?.();
|
|
64
|
+
return {
|
|
65
|
+
verifyElementPermission: (code, projectCode = runtime.projectCode) => !!code && !!access?.verifyElementPermission?.(code, projectCode),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const subscribeLocation = (listener) => {
|
|
69
|
+
if (typeof window === 'undefined')
|
|
70
|
+
return () => undefined;
|
|
71
|
+
window.addEventListener('popstate', listener);
|
|
72
|
+
return () => window.removeEventListener('popstate', listener);
|
|
73
|
+
};
|
|
74
|
+
const browserSearch = () => typeof window === 'undefined' ? '' : window.location.search;
|
|
75
|
+
function useBrowserLocation() {
|
|
76
|
+
const search = useSyncExternalStore(subscribeLocation, browserSearch, () => '');
|
|
77
|
+
return { search };
|
|
78
|
+
}
|
|
79
|
+
export const useLocation = () => (runtime.useLocation || useBrowserLocation)();
|
|
80
|
+
export function navigate(path) {
|
|
81
|
+
// 跳转只接受站内根路径;宿主可注入带微前端 basename 的路由适配器。
|
|
82
|
+
if (!path.startsWith('/') ||
|
|
83
|
+
path.startsWith('//') ||
|
|
84
|
+
/[\\\u0000-\u001f\u007f]/.test(path))
|
|
85
|
+
throw new Error('消息跳转地址必须为站内路径');
|
|
86
|
+
if (runtime.navigate)
|
|
87
|
+
runtime.navigate(path);
|
|
88
|
+
else if (typeof window !== 'undefined')
|
|
89
|
+
window.location.assign(path);
|
|
90
|
+
}
|
|
91
|
+
export function openMessageInbox(record) {
|
|
92
|
+
if (runtime.onOpen)
|
|
93
|
+
return runtime.onOpen(record);
|
|
94
|
+
const [path, fragment] = runtime.inboxPath.split('#', 2);
|
|
95
|
+
const target = `${path}${path.includes('?') ? '&' : '?'}${encodeURIComponent(runtime.receiptQueryParam)}=${encodeURIComponent(record.receiptId || record.id)}`;
|
|
96
|
+
navigate(fragment === undefined ? target : `${target}#${fragment}`);
|
|
97
|
+
}
|
|
98
|
+
export function getMessageFieldLabel(field) {
|
|
99
|
+
const common = {
|
|
100
|
+
severity: '级别',
|
|
101
|
+
businessType: '业务类型',
|
|
102
|
+
regionId: '地区',
|
|
103
|
+
};
|
|
104
|
+
return runtime.fieldLabel?.(field) || common[field] || field;
|
|
105
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// 消息栏属于宿主内容流,不使用视口左右坐标猜测侧栏宽度。
|
|
2
|
+
.qiqb-message-reminder-layout {
|
|
3
|
+
width: 100%;
|
|
4
|
+
min-width: 0;
|
|
5
|
+
min-height: 100%;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.qiqb-message-reminder-slot {
|
|
9
|
+
// 横跨二级菜单与页面的共同内容区,贴合宿主页头下方,不额外留出页面卡片的边距。
|
|
10
|
+
position: relative;
|
|
11
|
+
width: 100%;
|
|
12
|
+
min-width: 0;
|
|
13
|
+
|
|
14
|
+
// Portal 有内容才留出位置;没有轮播消息时业务页面完全按原布局显示。
|
|
15
|
+
&:empty {
|
|
16
|
+
display: none;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 编辑主题的预览在弹窗上方显示,关闭预览后立即释放节点占位。
|
|
21
|
+
// 真实消息始终进入内容区槽位,只有配置预览使用这处窄幅浮层。
|
|
22
|
+
.qiqb-message-reminder-preview-slot {
|
|
23
|
+
position: fixed;
|
|
24
|
+
top: 20px;
|
|
25
|
+
left: 50%;
|
|
26
|
+
width: 860px;
|
|
27
|
+
max-width: calc(100vw - 32px);
|
|
28
|
+
transform: translateX(-50%);
|
|
29
|
+
|
|
30
|
+
&:empty {
|
|
31
|
+
display: none;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@media (max-width: 767px) {
|
|
36
|
+
.qiqb-message-reminder-preview-slot {
|
|
37
|
+
top: 12px;
|
|
38
|
+
width: calc(100vw - 24px);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MessageInboxChange, MessageRealtimeAdapter } from './index.js';
|
|
2
|
+
interface MessageRefreshOptions {
|
|
3
|
+
realtime?: MessageRealtimeAdapter;
|
|
4
|
+
refresh: (complete: boolean) => Promise<void>;
|
|
5
|
+
onChanged?: (change: MessageInboxChange) => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* 在线事件只唤醒 HTTP 投影;连接正常时低频对账,未接入或断线时保留原五秒补偿。
|
|
9
|
+
* 同一查询进行中收到事件会合并为下一次刷新,不能丢掉在途请求之后的新收件/已读变化。
|
|
10
|
+
*/
|
|
11
|
+
export declare function startMessageRealtimeRefresh(options: MessageRefreshOptions): {
|
|
12
|
+
wake: () => void;
|
|
13
|
+
release(): void;
|
|
14
|
+
};
|
|
15
|
+
export {};
|