feat(api): 完善家谱系统API客户端契约
- 实现家谱管理相关方法,包括创建、详情、概览、我的家谱和选项查询 - 添加家谱加入申请功能,支持申请、审核、取消和待审核列表操作 - 集成通知详情获取方法和通知ID安全验证机制 - 完善功德记录、谱文、相册、视频、祭祀活动的完整CRUD操作契约 - 实现家谱成员管理功能,包含成员列表、更新、移除和转让所有者操作 - 优化路径ID验证逻辑,拒绝不安全的数值ID并提供明确错误提示 - 更新测试用例以验证所有新增API方法的路径和请求体白名单机制
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) {
|
||||
module.exports = factory(root);
|
||||
return;
|
||||
}
|
||||
|
||||
root.CeremonyPages = factory(root);
|
||||
if (root.document) {
|
||||
root.document.addEventListener('DOMContentLoaded', function () {
|
||||
root.CeremonyPages.init();
|
||||
});
|
||||
}
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) {
|
||||
'use strict';
|
||||
|
||||
var documentRef = root.document;
|
||||
var invitationsById = Object.create(null);
|
||||
var selectedInvitationId = '';
|
||||
var writePending = false;
|
||||
var invitationStatuses = ['PENDING', 'ACCEPTED', 'DECLINED', 'CANCELED'];
|
||||
var responseStatuses = ['ACCEPTED', 'DECLINED'];
|
||||
var statusLabels = {
|
||||
PENDING: '待响应',
|
||||
ACCEPTED: '已接受',
|
||||
DECLINED: '已拒绝',
|
||||
CANCELED: '已取消'
|
||||
};
|
||||
|
||||
function getApi() {
|
||||
return root.GenealogyApi && root.GenealogyApi.defaultClient;
|
||||
}
|
||||
|
||||
function query(selector, rootNode) {
|
||||
return documentRef ? (rootNode || documentRef).querySelector(selector) : null;
|
||||
}
|
||||
|
||||
function shouldRedirectToLogin(api, error) {
|
||||
var status = error && (error.status || error.code);
|
||||
|
||||
return !api || !api.getToken || !api.getToken() || Number(status) === 401;
|
||||
}
|
||||
|
||||
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');
|
||||
return true;
|
||||
}
|
||||
root.location.href = 'login.html';
|
||||
return true;
|
||||
}
|
||||
|
||||
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 stringValue(value) {
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
function optionalInteger(value) {
|
||||
var number;
|
||||
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
number = Number(value);
|
||||
return Number.isSafeInteger(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value) {
|
||||
var number;
|
||||
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
number = Number(value);
|
||||
return Number.isFinite(number) ? number : undefined;
|
||||
}
|
||||
|
||||
function normalizeInvitation(item) {
|
||||
var invitationId = normalizeId(item && item.invitationId);
|
||||
var genealogyId = normalizeId(item && item.genealogyId);
|
||||
var ceremonyId = normalizeId(item && item.ceremonyId);
|
||||
var inviteeUserId = normalizeId(item && item.inviteeUserId);
|
||||
var inviteStatus = stringValue(item && item.inviteStatus);
|
||||
var invitation;
|
||||
var inviteVersion;
|
||||
var longitude;
|
||||
var latitude;
|
||||
|
||||
if (!invitationId || !genealogyId || !ceremonyId || invitationStatuses.indexOf(inviteStatus) < 0) {
|
||||
return null;
|
||||
}
|
||||
invitation = {
|
||||
invitationId: invitationId,
|
||||
genealogyId: genealogyId,
|
||||
ceremonyId: ceremonyId,
|
||||
inviteStatus: inviteStatus,
|
||||
deliveredTime: stringValue(item.deliveredTime),
|
||||
readTime: stringValue(item.readTime),
|
||||
responseTime: stringValue(item.responseTime),
|
||||
ceremonyTitle: stringValue(item.ceremonyTitle),
|
||||
ceremonyTime: stringValue(item.ceremonyTime),
|
||||
location: stringValue(item.location),
|
||||
locationAddress: stringValue(item.locationAddress)
|
||||
};
|
||||
if (inviteeUserId) invitation.inviteeUserId = inviteeUserId;
|
||||
inviteVersion = optionalInteger(item.inviteVersion);
|
||||
longitude = optionalNumber(item.longitude);
|
||||
latitude = optionalNumber(item.latitude);
|
||||
if (inviteVersion !== undefined) invitation.inviteVersion = inviteVersion;
|
||||
if (longitude !== undefined) invitation.longitude = longitude;
|
||||
if (latitude !== undefined) invitation.latitude = latitude;
|
||||
return invitation;
|
||||
}
|
||||
|
||||
function normalizeList(data) {
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
function buildInvitationResponseBody(values) {
|
||||
return {
|
||||
inviteStatus: stringValue(values && values.inviteStatus)
|
||||
};
|
||||
}
|
||||
|
||||
function validateInvitationResponseBody(body) {
|
||||
return !body || responseStatuses.indexOf(body.inviteStatus) < 0 ? '邀请只能选择接受或拒绝' : '';
|
||||
}
|
||||
|
||||
function canRespondToInvitation(invitation) {
|
||||
return Boolean(invitation && invitation.inviteStatus === 'PENDING');
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return stringValue(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function invitationTitle(invitation) {
|
||||
return invitation.ceremonyTitle || '未命名贺礼活动';
|
||||
}
|
||||
|
||||
function renderInvitationRow(invitation) {
|
||||
var actions = '<button class="pill" type="button" data-invitation-detail="' +
|
||||
escapeHtml(invitation.invitationId) + '">查看详情</button>';
|
||||
|
||||
if (canRespondToInvitation(invitation)) {
|
||||
actions += '<button class="pill" type="button" data-invitation-response="ACCEPTED" data-invitation-id="' +
|
||||
escapeHtml(invitation.invitationId) + '">接受邀请</button>' +
|
||||
'<button class="pill is-danger" type="button" data-invitation-response="DECLINED" data-invitation-id="' +
|
||||
escapeHtml(invitation.invitationId) + '">拒绝邀请</button>';
|
||||
}
|
||||
return '<article class="module-row ceremony-invitation-row" data-invitation-row="' +
|
||||
escapeHtml(invitation.invitationId) + '"><div><h3>' + escapeHtml(invitationTitle(invitation)) +
|
||||
'</h3><p>' + escapeHtml(statusLabels[invitation.inviteStatus]) +
|
||||
(invitation.ceremonyTime ? ' · ' + escapeHtml(invitation.ceremonyTime) : '') +
|
||||
(invitation.location ? ' · ' + escapeHtml(invitation.location) : '') +
|
||||
'</p></div><div class="row-actions">' + actions + '</div></article>';
|
||||
}
|
||||
|
||||
function renderDetailValue(label, value) {
|
||||
return '<p><span>' + escapeHtml(label) + '</span><b>' + escapeHtml(value || '未提供') + '</b></p>';
|
||||
}
|
||||
|
||||
function renderInvitationDetail(invitation) {
|
||||
var mapLink = '';
|
||||
|
||||
if (!invitation) return '<div class="api-empty">请选择一条邀请查看详情</div>';
|
||||
if (invitation.longitude !== undefined && invitation.latitude !== undefined) {
|
||||
mapLink = ' <a class="pill" target="_blank" rel="noopener noreferrer" href="https://uri.amap.com/marker?position=' +
|
||||
encodeURIComponent(invitation.longitude + ',' + invitation.latitude) + '&name=' +
|
||||
encodeURIComponent(invitation.location || invitation.locationAddress || invitationTitle(invitation)) +
|
||||
'">打开地图</a>';
|
||||
}
|
||||
return '<div class="form-like invitation-detail">' +
|
||||
renderDetailValue('活动标题', invitationTitle(invitation)) +
|
||||
renderDetailValue('邀请状态', statusLabels[invitation.inviteStatus]) +
|
||||
renderDetailValue('活动时间', invitation.ceremonyTime) +
|
||||
renderDetailValue('地点', invitation.location) +
|
||||
'<p><span>详细地址</span><b>' + escapeHtml(invitation.locationAddress || '未提供') + mapLink + '</b></p>' +
|
||||
renderDetailValue('投递时间', invitation.deliveredTime) +
|
||||
renderDetailValue('阅读时间', invitation.readTime) +
|
||||
renderDetailValue('响应时间', invitation.responseTime) +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderDetail(invitation) {
|
||||
var container = query('[data-my-invitation-detail]');
|
||||
|
||||
selectedInvitationId = invitation ? invitation.invitationId : '';
|
||||
if (container) container.innerHTML = renderInvitationDetail(invitation);
|
||||
}
|
||||
|
||||
function renderInvitations(data) {
|
||||
var container = query('[data-my-invitation-list]');
|
||||
var normalized = normalizeList(data).map(normalizeInvitation);
|
||||
var invalidCount = normalized.filter(function (item) { return !item; }).length;
|
||||
var invitations = normalized.filter(Boolean);
|
||||
|
||||
invitationsById = Object.create(null);
|
||||
invitations.forEach(function (invitation) {
|
||||
invitationsById[invitation.invitationId] = invitation;
|
||||
});
|
||||
if (!container) return invitations;
|
||||
if (invalidCount) {
|
||||
container.innerHTML = '<div class="api-empty">邀请响应缺少稳定 ID 或合法状态,请联系后端核对 DTO。</div>';
|
||||
renderDetail(null);
|
||||
return [];
|
||||
}
|
||||
if (!invitations.length) {
|
||||
container.innerHTML = '<div class="api-empty">暂无贺礼邀请</div>';
|
||||
renderDetail(null);
|
||||
return [];
|
||||
}
|
||||
container.innerHTML = invitations.map(renderInvitationRow).join('');
|
||||
renderDetail(invitationsById[selectedInvitationId] || invitations[0]);
|
||||
return invitations;
|
||||
}
|
||||
|
||||
function setWritePending(pending) {
|
||||
var buttons;
|
||||
|
||||
writePending = pending;
|
||||
if (!documentRef) return;
|
||||
buttons = documentRef.querySelectorAll('[data-invitation-response]');
|
||||
Array.prototype.forEach.call(buttons, function (button) {
|
||||
button.disabled = pending;
|
||||
});
|
||||
}
|
||||
|
||||
function setListState(message) {
|
||||
var container = query('[data-my-invitation-list]');
|
||||
|
||||
if (container) container.innerHTML = '<div class="api-empty">' + escapeHtml(message) + '</div>';
|
||||
}
|
||||
|
||||
async function loadMyInvitations() {
|
||||
var api = getApi();
|
||||
|
||||
if (redirectUnauthorized(api)) return;
|
||||
setListState('正在加载我的邀请…');
|
||||
try {
|
||||
renderInvitations(await api.myCeremonyInvitations());
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权查看邀请' : (error.message || '邀请加载失败'));
|
||||
renderDetail(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function respondToInvitation(invitationId, inviteStatus) {
|
||||
var api = getApi();
|
||||
var invitation = invitationsById[normalizeId(invitationId)];
|
||||
var body = buildInvitationResponseBody({ inviteStatus: inviteStatus });
|
||||
var validation = validateInvitationResponseBody(body);
|
||||
|
||||
if (writePending || redirectUnauthorized(api) || !invitation || !canRespondToInvitation(invitation)) return;
|
||||
if (validation) return;
|
||||
setWritePending(true);
|
||||
try {
|
||||
await api.respondCeremonyInvitation(invitation.genealogyId, invitation.ceremonyId, body);
|
||||
selectedInvitationId = invitation.invitationId;
|
||||
await loadMyInvitations();
|
||||
} catch (error) {
|
||||
if (redirectUnauthorized(api, error)) return;
|
||||
setListState(Number(error && (error.status || error.code)) === 403 ? '当前账号无权响应该邀请' : (error.message || '邀请响应失败'));
|
||||
} finally {
|
||||
setWritePending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function bindActions() {
|
||||
if (!documentRef) return;
|
||||
documentRef.addEventListener('click', function (event) {
|
||||
var detailButton = event.target.closest('[data-invitation-detail]');
|
||||
var responseButton = event.target.closest('[data-invitation-response]');
|
||||
|
||||
if (detailButton) {
|
||||
event.preventDefault();
|
||||
renderDetail(invitationsById[detailButton.getAttribute('data-invitation-detail')] || null);
|
||||
return;
|
||||
}
|
||||
if (!responseButton) return;
|
||||
event.preventDefault();
|
||||
respondToInvitation(
|
||||
responseButton.getAttribute('data-invitation-id'),
|
||||
responseButton.getAttribute('data-invitation-response')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!documentRef || !query('[data-ceremony-page]')) return;
|
||||
bindActions();
|
||||
loadMyInvitations();
|
||||
}
|
||||
|
||||
return {
|
||||
normalizeInvitation: normalizeInvitation,
|
||||
normalizeList: normalizeList,
|
||||
buildInvitationResponseBody: buildInvitationResponseBody,
|
||||
validateInvitationResponseBody: validateInvitationResponseBody,
|
||||
canRespondToInvitation: canRespondToInvitation,
|
||||
renderInvitationRow: renderInvitationRow,
|
||||
renderInvitationDetail: renderInvitationDetail,
|
||||
shouldRedirectToLogin: shouldRedirectToLogin,
|
||||
loadMyInvitations: loadMyInvitations,
|
||||
init: init
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user