feat(api): 完善家谱系统API客户端契约
- 实现家谱管理相关方法,包括创建、详情、概览、我的家谱和选项查询 - 添加家谱加入申请功能,支持申请、审核、取消和待审核列表操作 - 集成通知详情获取方法和通知ID安全验证机制 - 完善功德记录、谱文、相册、视频、祭祀活动的完整CRUD操作契约 - 实现家谱成员管理功能,包含成员列表、更新、移除和转让所有者操作 - 优化路径ID验证逻辑,拒绝不安全的数值ID并提供明确错误提示 - 更新测试用例以验证所有新增API方法的路径和请求体白名单机制
This commit is contained in:
+287
-45
@@ -14,118 +14,360 @@
|
||||
'use strict';
|
||||
|
||||
var documentRef = root.document;
|
||||
var notificationsById = Object.create(null);
|
||||
var selectedNotificationId = '';
|
||||
var writePending = false;
|
||||
|
||||
function getApi() {
|
||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||
}
|
||||
|
||||
function normalizeNotifications(data) {
|
||||
return Array.isArray(data) ? data : [];
|
||||
function query(selector, node) {
|
||||
return documentRef ? (node || documentRef).querySelector(selector) : null;
|
||||
}
|
||||
|
||||
function formatNotification(data) {
|
||||
return JSON.stringify(data || {}, null, 2);
|
||||
function queryAll(selector, node) {
|
||||
return documentRef ? Array.prototype.slice.call((node || documentRef).querySelectorAll(selector)) : [];
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
if (typeof value === 'number' && !Number.isSafeInteger(value)) return '';
|
||||
return /^[1-9][0-9]*$/.test(String(value)) ? String(value) : '';
|
||||
}
|
||||
|
||||
function normalizeNullableId(value) {
|
||||
var id;
|
||||
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
id = normalizeId(value);
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
function buildNotificationQuery(readStatus) {
|
||||
return readStatus === '0' || readStatus === '1' ? { readStatus: readStatus } : {};
|
||||
}
|
||||
|
||||
function normalizeNotification(item) {
|
||||
var notificationId = normalizeId(item && item.notificationId);
|
||||
var genealogyId = normalizeNullableId(item && item.genealogyId);
|
||||
var senderUserId = normalizeNullableId(item && item.senderUserId);
|
||||
var bizId = normalizeNullableId(item && item.bizId);
|
||||
var readStatus = stringValue(item && item.readStatus);
|
||||
var status = stringValue(item && item.status);
|
||||
|
||||
if (!notificationId || genealogyId === undefined || senderUserId === undefined || bizId === undefined) return null;
|
||||
if ((readStatus !== '0' && readStatus !== '1') || (status !== '0' && status !== '1')) return null;
|
||||
return {
|
||||
notificationId: notificationId,
|
||||
genealogyId: genealogyId,
|
||||
genealogyNo: stringValue(item.genealogyNo),
|
||||
genealogyName: stringValue(item.genealogyName),
|
||||
senderUserId: senderUserId,
|
||||
senderNickName: stringValue(item.senderNickName),
|
||||
senderPhone: stringValue(item.senderPhone),
|
||||
noticeType: stringValue(item.noticeType),
|
||||
noticeTitle: stringValue(item.noticeTitle),
|
||||
noticeContent: stringValue(item.noticeContent),
|
||||
bizType: stringValue(item.bizType),
|
||||
bizId: bizId,
|
||||
bizSummary: stringValue(item.bizSummary),
|
||||
publishTime: stringValue(item.publishTime),
|
||||
readStatus: readStatus,
|
||||
readTime: item.readTime === undefined || item.readTime === null ? null : String(item.readTime),
|
||||
status: status,
|
||||
remark: stringValue(item.remark)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNotifications(data) {
|
||||
var normalized;
|
||||
|
||||
if (!Array.isArray(data)) return [];
|
||||
normalized = data.map(normalizeNotification);
|
||||
return normalized.some(function (item) { return !item; }) ? [] : normalized;
|
||||
}
|
||||
|
||||
function getUnreadCount(data) {
|
||||
var count = Number(data);
|
||||
|
||||
return Number.isFinite(count) && count >= 0 ? count : 0;
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : 0;
|
||||
}
|
||||
|
||||
function query(selector, rootNode) {
|
||||
return (rootNode || documentRef).querySelector(selector);
|
||||
function matchesNotification(item, expectedNotificationId) {
|
||||
var notification = normalizeNotification(item);
|
||||
|
||||
return Boolean(notification && notification.notificationId === normalizeId(expectedNotificationId));
|
||||
}
|
||||
|
||||
function canMarkRead(notification) {
|
||||
return Boolean(notification && notification.status === '0' && notification.readStatus === '0');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return stringValue(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderNotificationRow(notification) {
|
||||
var statusText = notification.readStatus === '0' ? '未读' : '已读';
|
||||
var action = canMarkRead(notification)
|
||||
? '<button class="pill" type="button" data-notification-read-id="' + escapeHtml(notification.notificationId) + '">标记已读</button>'
|
||||
: '';
|
||||
|
||||
return '<article class="module-row notification-row' + (notification.readStatus === '0' ? ' is-unread' : '') + '">' +
|
||||
'<div><h3>' + escapeHtml(notification.noticeTitle || '通知') + '</h3><p>' +
|
||||
escapeHtml(notification.noticeContent || '暂无通知内容') + '</p><p>' +
|
||||
escapeHtml([notification.publishTime, statusText].filter(Boolean).join(' · ')) +
|
||||
'</p></div><div class="row-actions"><button class="pill" type="button" data-notification-detail-id="' +
|
||||
escapeHtml(notification.notificationId) + '">查看详情</button>' + action + '</div></article>';
|
||||
}
|
||||
|
||||
function detailValue(label, value) {
|
||||
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
|
||||
}
|
||||
|
||||
function renderNotificationDetail(notification) {
|
||||
var action;
|
||||
|
||||
if (!notification) return '<div class="api-empty">请选择一条通知查看详情</div>';
|
||||
action = canMarkRead(notification)
|
||||
? '<div class="bottom-actions"><button class="btn ghost" type="button" data-notification-read-id="' +
|
||||
escapeHtml(notification.notificationId) + '">标记为已读</button></div>'
|
||||
: '';
|
||||
return '<div class="form-like notification-detail">' +
|
||||
detailValue('通知标题', notification.noticeTitle) +
|
||||
detailValue('通知内容', notification.noticeContent) +
|
||||
detailValue('通知类型', notification.noticeType) +
|
||||
detailValue('家谱', notification.genealogyName) +
|
||||
detailValue('发送人', notification.senderNickName) +
|
||||
detailValue('业务类型', notification.bizType) +
|
||||
detailValue('业务摘要', notification.bizSummary) +
|
||||
detailValue('发布时间', notification.publishTime) +
|
||||
detailValue('阅读状态', notification.readStatus === '0' ? '未读' : '已读') +
|
||||
detailValue('阅读时间', notification.readTime) +
|
||||
detailValue('备注', notification.remark) +
|
||||
'</div>' + action;
|
||||
}
|
||||
|
||||
function showMessage(message) {
|
||||
if (root.layui && root.layui.layer) {
|
||||
root.layui.layer.msg(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.alert) root.alert(message);
|
||||
if (root.layui && root.layui.layer) root.layui.layer.msg(message);
|
||||
else if (root.alert) root.alert(message);
|
||||
}
|
||||
|
||||
function renderNotifications(container, notifications) {
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
function shouldRedirectToLogin(api, error) {
|
||||
var status = error && (error.status || error.code);
|
||||
|
||||
if (!notifications.length) {
|
||||
container.textContent = '暂无通知';
|
||||
return;
|
||||
}
|
||||
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||
}
|
||||
|
||||
notifications.forEach(function (notification) {
|
||||
var row = documentRef.createElement('pre');
|
||||
|
||||
row.className = 'notification-raw';
|
||||
row.textContent = formatNotification(notification);
|
||||
container.appendChild(row);
|
||||
});
|
||||
function redirectUnauthorized(api, error) {
|
||||
if (!shouldRedirectToLogin(api, error)) return false;
|
||||
if (api && api.clearToken) api.clearToken();
|
||||
if (!root.location) return true;
|
||||
if (typeof root.location.replace === 'function') root.location.replace('login.html');
|
||||
else root.location.href = 'login.html';
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateStatus(message) {
|
||||
var status = query('[data-notification-status]');
|
||||
|
||||
if (status) status.textContent = message;
|
||||
if (status) status.textContent = message || '';
|
||||
}
|
||||
|
||||
function setWritePending(value) {
|
||||
writePending = Boolean(value);
|
||||
queryAll('[data-notification-read-id], [data-notification-read-all], [data-notification-refresh], [data-notification-filter]').forEach(function (control) {
|
||||
control.disabled = writePending;
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications(data) {
|
||||
var container = query('[data-notification-list]');
|
||||
var normalized = Array.isArray(data) ? data.map(normalizeNotification) : [];
|
||||
var invalidCount = normalized.filter(function (item) { return !item; }).length;
|
||||
var notifications = normalized.filter(Boolean);
|
||||
|
||||
notificationsById = Object.create(null);
|
||||
notifications.forEach(function (notification) {
|
||||
notificationsById[notification.notificationId] = notification;
|
||||
});
|
||||
if (!container) return notifications;
|
||||
if (!Array.isArray(data) || invalidCount) {
|
||||
container.innerHTML = '<div class="api-empty">通知列表响应缺少稳定 NotificationView 字段,请联系后端核对。</div>';
|
||||
return [];
|
||||
}
|
||||
if (!notifications.length) {
|
||||
container.innerHTML = '<div class="api-empty">暂无通知</div>';
|
||||
return [];
|
||||
}
|
||||
container.innerHTML = notifications.map(renderNotificationRow).join('');
|
||||
return notifications;
|
||||
}
|
||||
|
||||
function renderDetail(notification) {
|
||||
var container = query('[data-notification-detail]');
|
||||
|
||||
if (container) container.innerHTML = renderNotificationDetail(notification);
|
||||
}
|
||||
|
||||
async function loadNotificationDetail(notificationId) {
|
||||
var api = getApi();
|
||||
var notification;
|
||||
|
||||
if (!normalizeId(notificationId) || redirectUnauthorized(api)) return null;
|
||||
try {
|
||||
notification = normalizeNotification(await api.notificationDetail(notificationId));
|
||||
if (!notification || !matchesNotification(notification, notificationId)) {
|
||||
throw new Error('通知详情未返回同一条稳定 NotificationView');
|
||||
}
|
||||
selectedNotificationId = notification.notificationId;
|
||||
notificationsById[notification.notificationId] = notification;
|
||||
renderDetail(notification);
|
||||
return notification;
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return null;
|
||||
renderDetail(null);
|
||||
updateStatus(error.message || '通知详情加载失败');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentFilter() {
|
||||
var filter = query('[data-notification-filter]');
|
||||
|
||||
return filter ? filter.value : '';
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
var api = getApi();
|
||||
var container = query('[data-notification-list]');
|
||||
var countTarget = query('[data-notification-unread-count]');
|
||||
var result;
|
||||
var notifications;
|
||||
var nextSelectedId;
|
||||
|
||||
if (!api || !api.notifications || !api.unreadNotificationCount) {
|
||||
if (redirectUnauthorized(api)) return;
|
||||
if (!api.notifications || !api.notificationDetail || !api.unreadNotificationCount) {
|
||||
updateStatus('消息接口初始化失败,请刷新后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus('正在同步消息…');
|
||||
try {
|
||||
result = await Promise.all([api.notifications(), api.unreadNotificationCount()]);
|
||||
renderNotifications(container, normalizeNotifications(result[0]));
|
||||
result = await Promise.all([
|
||||
api.notifications(buildNotificationQuery(currentFilter())),
|
||||
api.unreadNotificationCount()
|
||||
]);
|
||||
notifications = renderNotifications(result[0]);
|
||||
if (countTarget) countTarget.textContent = String(getUnreadCount(result[1]));
|
||||
updateStatus('已同步消息。列表元素 DTO 尚未在 PC 契约展开,以下保留服务端原始记录。');
|
||||
nextSelectedId = selectedNotificationId && notificationsById[selectedNotificationId]
|
||||
? selectedNotificationId
|
||||
: (notifications[0] && notifications[0].notificationId);
|
||||
if (nextSelectedId) await loadNotificationDetail(nextSelectedId);
|
||||
else renderDetail(null);
|
||||
updateStatus('消息已同步');
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
renderNotifications(null);
|
||||
renderDetail(null);
|
||||
updateStatus(error.message || '消息同步失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead(button) {
|
||||
async function markNotificationRead(notificationId) {
|
||||
var api = getApi();
|
||||
var notification = notificationsById[notificationId];
|
||||
|
||||
if (writePending || !normalizeId(notificationId) || !canMarkRead(notification) || redirectUnauthorized(api)) return;
|
||||
setWritePending(true);
|
||||
try {
|
||||
await api.markNotificationRead(notificationId);
|
||||
selectedNotificationId = notificationId;
|
||||
showMessage('通知已标记为已读');
|
||||
await loadNotifications();
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
updateStatus(error.message || '标记通知已读失败');
|
||||
} finally {
|
||||
setWritePending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
var api = getApi();
|
||||
|
||||
if (!api || !api.markAllNotificationsRead) {
|
||||
if (writePending || redirectUnauthorized(api)) return;
|
||||
if (!api.markAllNotificationsRead) {
|
||||
updateStatus('消息接口初始化失败,请刷新后重试');
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
setWritePending(true);
|
||||
try {
|
||||
await api.markAllNotificationsRead();
|
||||
showMessage('已全部标记为已读');
|
||||
await loadNotifications();
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
updateStatus(error.message || '全部标记已读失败');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
setWritePending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindActions() {
|
||||
if (!documentRef) return;
|
||||
documentRef.addEventListener('click', function (event) {
|
||||
var detailButton = event.target.closest('[data-notification-detail-id]');
|
||||
var readButton = event.target.closest('[data-notification-read-id]');
|
||||
|
||||
if (detailButton) {
|
||||
event.preventDefault();
|
||||
loadNotificationDetail(detailButton.getAttribute('data-notification-detail-id'));
|
||||
return;
|
||||
}
|
||||
if (readButton) {
|
||||
event.preventDefault();
|
||||
markNotificationRead(readButton.getAttribute('data-notification-read-id'));
|
||||
}
|
||||
});
|
||||
if (query('[data-notification-read-all]')) {
|
||||
query('[data-notification-read-all]').addEventListener('click', markAllRead);
|
||||
}
|
||||
if (query('[data-notification-refresh]')) {
|
||||
query('[data-notification-refresh]').addEventListener('click', loadNotifications);
|
||||
}
|
||||
if (query('[data-notification-filter]')) {
|
||||
query('[data-notification-filter]').addEventListener('change', function () {
|
||||
selectedNotificationId = '';
|
||||
loadNotifications();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
var readAll = query('[data-notification-read-all]');
|
||||
var refresh = query('[data-notification-refresh]');
|
||||
|
||||
if (!documentRef || !query('[data-notification-list]')) return;
|
||||
if (readAll) readAll.addEventListener('click', function () { markAllRead(readAll); });
|
||||
if (refresh) refresh.addEventListener('click', loadNotifications);
|
||||
bindActions();
|
||||
loadNotifications();
|
||||
}
|
||||
|
||||
return {
|
||||
buildNotificationQuery: buildNotificationQuery,
|
||||
normalizeNotification: normalizeNotification,
|
||||
normalizeNotifications: normalizeNotifications,
|
||||
formatNotification: formatNotification,
|
||||
getUnreadCount: getUnreadCount,
|
||||
matchesNotification: matchesNotification,
|
||||
canMarkRead: canMarkRead,
|
||||
renderNotificationRow: renderNotificationRow,
|
||||
renderNotificationDetail: renderNotificationDetail,
|
||||
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||
loadNotifications: loadNotifications,
|
||||
init: init
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user