diff --git a/app.html b/app.html index d246646..f7cea48 100644 --- a/app.html +++ b/app.html @@ -105,7 +105,7 @@

展示后台维护的应用推广、公告和下载引导内容。

-
应用推广内容加载中
+
正在读取应用推广…
@@ -142,6 +142,7 @@ + diff --git a/article-detail.html b/article-detail.html index 66f3de1..758c247 100644 --- a/article-detail.html +++ b/article-detail.html @@ -33,10 +33,10 @@
-

+
-
资讯详情加载中
+
正在读取资讯详情…
+ diff --git a/create-genealogy.html b/create-genealogy.html index acd0d3c..5bc302b 100644 --- a/create-genealogy.html +++ b/create-genealogy.html @@ -7,7 +7,7 @@ - + @@ -48,10 +48,10 @@

动态内容

-
+
- 内容
-

+
@@ -77,7 +77,9 @@

历史反馈

-
+
+
正在读取反馈记录…
+
@@ -91,8 +93,8 @@ - + diff --git a/profile-generation.html b/profile-generation.html index fc23ae6..c1ecd57 100644 --- a/profile-generation.html +++ b/profile-generation.html @@ -55,9 +55,9 @@

字辈列表

-
正在加载字辈记录…
+
正在加载字辈记录…
diff --git a/profile-gift-edit.html b/profile-gift-edit.html index d5a250f..8f2c244 100644 --- a/profile-gift-edit.html +++ b/profile-gift-edit.html @@ -3,7 +3,7 @@ - 创建贺礼 - 个人中心 - 代代相传 + 创建祭祀活动 - 个人中心 - 代代相传 @@ -17,18 +17,18 @@ >
-
Gift Editor
+
Ceremony Editor

创建祭祀活动

@@ -42,17 +42,17 @@

活动信息

-
正在加载活动编辑信息…
-
- 活动说明
@@ -105,17 +105,17 @@ -

未选择文件

+

未选择文件

-

+
取消
diff --git a/profile-gift.html b/profile-gift.html index 59a7615..c7f1396 100644 --- a/profile-gift.html +++ b/profile-gift.html @@ -49,8 +49,11 @@

我的邀请

-
正在加载我的邀请…
+
+ 正在加载我的邀请… +
+
@@ -61,7 +64,9 @@

邀请详情

-
请选择一条邀请查看详情
+
+ 请选择一条邀请查看详情 +
diff --git a/profile-growth-edit.html b/profile-growth-edit.html index dfad415..b58b8b0 100644 --- a/profile-growth-edit.html +++ b/profile-growth-edit.html @@ -48,7 +48,7 @@

记录内容

- +
- +
@@ -99,7 +99,7 @@ -

未选择文件

+

未选择文件

@@ -108,15 +108,17 @@
+
取消 -
-
正在加载成长记录编辑信息…
+
+
正在加载成长记录编辑信息…
+
diff --git a/profile-growth.html b/profile-growth.html index 4189292..272ef5e 100644 --- a/profile-growth.html +++ b/profile-growth.html @@ -20,7 +20,7 @@ @@ -50,13 +50,13 @@

记录列表

-
正在加载成长记录…
+
正在加载成长记录…

记录详情

-
请选择一条成长记录查看详情
+
请选择一条成长记录查看详情。
diff --git a/profile-invite.html b/profile-invite.html index 99dedff..4b87920 100644 --- a/profile-invite.html +++ b/profile-invite.html @@ -32,7 +32,7 @@

邀请家人

生成家谱邀请链接或邀请码,引导亲人加入家谱并激活账号。

- 打开加入页 + 返回家族管理
@@ -50,12 +50,12 @@

邀请码

-

当前邀请码:加载中...

+

当前 PC 接口未提供邀请码,暂不能生成或展示。

邀请链接

-

邀请链接加载中...

+

当前 PC 接口未提供邀请链接,暂不能生成或复制。

diff --git a/profile-join-family.html b/profile-join-family.html index 3a50056..34a6526 100644 --- a/profile-join-family.html +++ b/profile-join-family.html @@ -53,7 +53,7 @@

我的加入申请

-

+
diff --git a/profile-join-review.html b/profile-join-review.html index 2195f52..f27cfa8 100644 --- a/profile-join-review.html +++ b/profile-join-review.html @@ -42,7 +42,7 @@ 消息中心入谱审核邀请家人即将开放管理员权限即将开放 + >管理员权限
@@ -50,8 +50,28 @@

待审核申请

-

-
+
+
+
+ 正在读取待审核申请… +
+
+ + diff --git a/profile-memo-edit.html b/profile-memo-edit.html index 8738d3c..d0ff645 100644 --- a/profile-memo-edit.html +++ b/profile-memo-edit.html @@ -26,21 +26,22 @@

备忘内容

- -
正在加载备忘录编辑信息…
+
正在加载备忘录编辑信息…
diff --git a/profile-memo.html b/profile-memo.html index a186d8a..801f204 100644 --- a/profile-memo.html +++ b/profile-memo.html @@ -13,7 +13,7 @@
@@ -30,13 +30,13 @@

备忘列表

-
正在加载备忘录…
+
正在加载备忘录…

备忘详情

-
请选择一条备忘录查看详情
+
请选择一条备忘录查看详情。
diff --git a/profile-merit-edit.html b/profile-merit-edit.html index d870166..595f912 100644 --- a/profile-merit-edit.html +++ b/profile-merit-edit.html @@ -49,10 +49,10 @@

功德资料

- -
正在加载功德记录编辑信息…
+
+
正在加载功德记录编辑信息…
+
diff --git a/profile-merit.html b/profile-merit.html index 9f00959..35ddc5f 100644 --- a/profile-merit.html +++ b/profile-merit.html @@ -21,7 +21,7 @@ @@ -50,13 +50,13 @@

功德记录

-
正在加载功德记录…
+
正在加载功德记录…

记录详情

-
请选择一条功德记录查看详情
+
请选择一条功德记录查看详情。
diff --git a/profile-messages.html b/profile-messages.html index 09acfac..c92626a 100644 --- a/profile-messages.html +++ b/profile-messages.html @@ -69,15 +69,15 @@ -

正在加载消息…

+
-
正在加载消息…
+
正在加载消息…

通知详情

-
请选择一条通知查看详情
+
请选择一条通知查看详情。
diff --git a/profile-relative-edit.html b/profile-relative-edit.html index 69b254c..38c1fc6 100644 --- a/profile-relative-edit.html +++ b/profile-relative-edit.html @@ -48,10 +48,10 @@

记录内容

-
diff --git a/profile-relative.html b/profile-relative.html index 7e260f1..93fedbc 100644 --- a/profile-relative.html +++ b/profile-relative.html @@ -21,7 +21,7 @@ @@ -49,13 +49,13 @@

记录列表

-
正在加载亲友记录…
+
正在加载亲友记录…

记录详情

-
请选择一条亲友记录查看详情
+
请选择一条亲友记录查看详情。
diff --git a/profile-security.html b/profile-security.html index ab0a665..f050115 100644 --- a/profile-security.html +++ b/profile-security.html @@ -31,7 +31,7 @@

安全设置

-

维护登录密码、手机号、微信快捷登录和账号安全提醒。

+

维护登录密码、手机号与账号注销安全操作。

@@ -48,34 +48,34 @@

账号安全

-
+
- +
- +
- +
- - 等待提交 + +

换绑手机号

-
+
- +
- +
- - 等待验证码 + +

注销账号

验证已绑定手机号的短信验证码后提交注销申请。

-
+
- + 获取验证码 -

正在读取已绑定手机号

+
正在读取已绑定手机号…
- +
- + - 等待验证 +
@@ -155,10 +155,10 @@ + - diff --git a/profile-services.html b/profile-services.html index 14cdda1..9553fc5 100644 --- a/profile-services.html +++ b/profile-services.html @@ -65,10 +65,16 @@
-

应用分享

-

邀请奖励服务正在开发中,当前不提供不可用的跳转入口。

+

应用分享与收益

+

分享奖励、推广收益与提现服务正在开发中,当前不提供不可用的跳转入口。

即将开放
+
+ +

个性化推荐

+

当前 PC 接口未提供推荐偏好的读取或保存,暂不能设置推荐开关。

+ 暂不可设置 +

意见反馈

@@ -96,14 +102,40 @@ >
+
+

平台设置与协议

+

集中查看 APP“设置”中的平台说明、联系方式及协议入口;正式生效内容以平台和法务发布版本为准。

+
+

会员服务

选择适合的会员套餐并创建订单。当前 PC 暂未开放在线支付。

-
会员套餐加载中
+
正在读取会员套餐…
-
-

尚未选择套餐

+ +

尚未选择套餐

+ @@ -104,14 +104,15 @@
-

+

+
-
成员加载中...
+
世系人物加载中…
@@ -119,24 +120,27 @@

成员详情

-
请选择一个成员查看资料
+
请选择一个成员查看资料
diff --git a/profile-video-edit.html b/profile-video-edit.html index d6ac5bd..2205907 100644 --- a/profile-video-edit.html +++ b/profile-video-edit.html @@ -47,9 +47,9 @@

视频内容

-
+
取消 -
-
正在核对视频维护权限…
+
正在核对视频维护权限…
diff --git a/profile-video.html b/profile-video.html index 412ed15..1562fc3 100644 --- a/profile-video.html +++ b/profile-video.html @@ -50,13 +50,17 @@

视频列表

-
正在加载视频…
+
+ 正在加载视频… +

视频详情

-
请选择一个视频查看详情
+
+ 请选择一个视频查看详情 +
diff --git a/profile.html b/profile.html index 3775b07..bd5fc43 100644 --- a/profile.html +++ b/profile.html @@ -46,7 +46,7 @@
已登录账户资料以实际接口为准
-

正在读取账户资料…

+
正在读取账户资料…
进入我的家谱 完善个人资料 @@ -111,8 +111,27 @@

绑定账号加载中

昵称加载中

+

真实姓名加载中

+

性别加载中

出生日期加载中

-

现居地区加载中

+

邮箱加载中

+
+ +
+
+
+

账户与服务

+

将 APP 的“我的”功能整理为桌面服务入口;家谱内容仍需从具体家谱进入。

+
+ 查看服务中心 +
+
@@ -172,8 +191,8 @@ - + diff --git a/public/css/create-genealogy.css b/public/css/create-genealogy.css index d55e10f..41cd88b 100644 --- a/public/css/create-genealogy.css +++ b/public/css/create-genealogy.css @@ -169,6 +169,18 @@ transition: border-color .22s ease, box-shadow .22s ease, background .22s ease, transform .22s ease; } +.create-form .field { + display: grid; + gap: 8px; + color: var(--ink); + font-weight: 800; +} + +.create-region-picker label { + display: grid; + gap: 8px; +} + .create-region-picker { /* 创建家谱页地区选择器占满表单宽度,编码通过隐藏字段提交 */ display: grid; diff --git a/public/css/join-genealogy.css b/public/css/join-genealogy.css index 64663ca..c58c404 100644 --- a/public/css/join-genealogy.css +++ b/public/css/join-genealogy.css @@ -1,6 +1,51 @@ /* 加入家谱页只保留单页差异,公共认证卡片样式在 public.css */ .page-join-genealogy .auth-card { - width: min(500px, 100%); + width: min(1080px, 100%); +} + +.join-page-grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(340px, .85fr); + gap: 28px; + align-items: start; +} + +.join-search-panel, +.join-apply-form { + display: grid; + gap: 16px; +} + +.join-search-panel { + padding-right: 28px; + border-right: 1px solid var(--line); +} + +.join-page-grid h2 { + margin: 0; + font-size: 22px; +} + +.join-page-grid .module-list { + max-height: 420px; + overflow: auto; +} + +@media (max-width: 800px) { + .page-join-genealogy .auth-card { + width: min(500px, 100%); + } + + .join-page-grid { + grid-template-columns: 1fr; + } + + .join-search-panel { + padding-right: 0; + padding-bottom: 24px; + border-right: 0; + border-bottom: 1px solid var(--line); + } } .invite-box { diff --git a/public/css/profile-module.css b/public/css/profile-module.css index 7e66343..4382d8e 100644 --- a/public/css/profile-module.css +++ b/public/css/profile-module.css @@ -522,6 +522,11 @@ gap: 12px; } +.profile-region-picker > label { + display: grid; + gap: 6px; +} + .profile-region-picker select { min-height: 48px; padding: 12px 14px; @@ -745,11 +750,16 @@ gap: 10px; } -.editor-field label { +.editor-field label, +.editor-label { color: var(--ink); font-weight: 900; } +.field-required { + color: var(--red); +} + .editor-field input, .editor-field select, .editor-field textarea { diff --git a/public/css/profile.css b/public/css/profile.css index 128fa44..700f7aa 100644 --- a/public/css/profile.css +++ b/public/css/profile.css @@ -458,6 +458,26 @@ transform: translateY(-3px); } +.service-grid strong, +.service-grid span { + display: block; + text-align: center; +} + +.service-grid strong { + font-size: 17px; +} + +.service-grid span { + color: var(--muted); + font-size: 13px; + line-height: 1.55; +} + +.service-grid a:hover span { + color: rgba(255, 255, 255, .8); +} + .tilt-card { transform: perspective(900px) rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg)) translateY(var(--lift, 0)); transition: transform .18s ease, box-shadow .24s ease, border-color .24s ease; diff --git a/public/css/public.css b/public/css/public.css index 18b1bcb..85f18aa 100644 --- a/public/css/public.css +++ b/public/css/public.css @@ -551,6 +551,21 @@ textarea { gap: 14px; } +.auth-card .auth-field { + display: grid; + gap: 8px; + color: var(--ink); + font-weight: 800; +} + +.auth-card .auth-field b { + color: var(--red); +} + +.auth-card .auth-form-status:empty { + display: none; +} + .auth-card .input { height: 50px; background: #fffaf1; @@ -637,8 +652,9 @@ textarea { opacity: .72; } -/* 接口列表为空时的统一占位样式 */ -.api-empty { +/* 业务请求的加载、空数据、失败和无权限状态 */ +.api-empty, +.api-state { padding: 22px; border: 1px dashed var(--line); border-radius: 14px; @@ -648,6 +664,17 @@ textarea { font-weight: 800; } +.api-state--loading { + color: var(--muted); +} + +.api-state--error, +.api-state--forbidden { + border-color: rgba(160, 50, 45, .28); + color: var(--red); + background: rgba(160, 50, 45, .06); +} + .footer { padding: 60px 0 36px; color: rgba(255, 255, 255, .78); diff --git a/public/js/admin-permission-pages.js b/public/js/admin-permission-pages.js new file mode 100644 index 0000000..f9aba83 --- /dev/null +++ b/public/js/admin-permission-pages.js @@ -0,0 +1,171 @@ +(function (root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(root); + return; + } + + root.AdminPermissionPages = factory(root); + if (root.document) { + root.document.addEventListener('DOMContentLoaded', function () { + root.AdminPermissionPages.init(); + }); + } +})(typeof globalThis !== 'undefined' ? globalThis : window, function (root) { + 'use strict'; + + var documentRef = root.document; + var roleLabels = { + owner: '谱主', + admin: '管理员', + editor: '编辑', + member: '成员', + visitor: '访客' + }; + + function getApi() { + return root.GenealogyApi && root.GenealogyApi.defaultClient; + } + + function query(selector) { + return documentRef ? documentRef.querySelector(selector) : null; + } + + function normalizeId(value) { + var text; + + if (typeof value === 'number' && !Number.isSafeInteger(value)) return ''; + if (value === undefined || value === null) return ''; + text = String(value).trim(); + return /^[1-9][0-9]*$/.test(text) ? text : ''; + } + + function getCurrentGenealogyId(search) { + var source = search === undefined && root.location ? root.location.search : search; + var params; + var contextId; + + if (search === undefined && root.ProfileUI && root.ProfileUI.getGenealogyId) { + contextId = normalizeId(root.ProfileUI.getGenealogyId()); + if (contextId) return contextId; + } + params = new URLSearchParams(String(source || '').replace(/^\?/, '')); + return normalizeId(params.get('genealogyId')); + } + + function normalizePermissionOverview(data, expectedGenealogyId) { + var source = data || {}; + var genealogyId = normalizeId(source.genealogyId); + var roleType = String(source.roleType === undefined || source.roleType === null ? '' : source.roleType).trim(); + var expectedId = normalizeId(expectedGenealogyId); + + if (!genealogyId || !expectedId || genealogyId !== expectedId || !Object.prototype.hasOwnProperty.call(roleLabels, roleType)) { + return null; + } + return { + genealogyId: genealogyId, + roleType: roleType, + canManage: source.canManage === true, + canEditContent: source.canEditContent === true + }; + } + + function canMaintainContent(access) { + return Boolean(access && (access.canEditContent === true || access.canManage === true)); + } + + function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderCurrentPermissions(access) { + if (!access) return ''; + return '

实际写入操作仍由服务端按当前角色与家谱权限校验。

'; + } + + function setState(type, message) { + var container = query('[data-admin-permission-current]'); + + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.textContent = message; + } + + function shouldRedirectToLogin(api, error) { + var status = error && (error.status || error.code); + + return !api || !api.getToken || !api.getToken() || Number(status) === 401; + } + + function redirectToLogin(api) { + if (api && api.clearToken) api.clearToken(); + if (!root.location) return; + if (typeof root.location.replace === 'function') root.location.replace('login.html'); + else root.location.href = 'login.html'; + } + + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + + async function loadPage() { + var api = getApi(); + var genealogyId; + var overview; + var container; + + if (shouldRedirectToLogin(api)) { + redirectToLogin(api); + return; + } + genealogyId = getCurrentGenealogyId(); + if (!genealogyId) { + setState('empty', '请先选择家谱,再查看当前角色权限。'); + return; + } + if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks(); + setState('loading', '正在核对当前家谱角色与权限…'); + try { + overview = normalizePermissionOverview(await api.genealogyDetail(genealogyId), genealogyId); + if (!overview) throw new Error('家谱权限响应缺少稳定角色或能力字段'); + container = query('[data-admin-permission-current]'); + if (container) container.innerHTML = renderCurrentPermissions(overview); + } catch (error) { + if (shouldRedirectToLogin(api, error)) { + redirectToLogin(api); + return; + } + setState( + isForbidden(error) ? 'forbidden' : 'error', + isForbidden(error) ? '当前账号无权查看该家谱权限。' : '当前角色权限读取失败,请稍后重试。' + ); + } + } + + function init() { + if (!documentRef || !query('[data-admin-permission-page]')) return; + loadPage(); + } + + return { + getCurrentGenealogyId: getCurrentGenealogyId, + normalizePermissionOverview: normalizePermissionOverview, + canMaintainContent: canMaintainContent, + renderCurrentPermissions: renderCurrentPermissions, + shouldRedirectToLogin: shouldRedirectToLogin, + isForbidden: isForbidden, + init: init + }; +}); diff --git a/public/js/album-pages.js b/public/js/album-pages.js index c8ff1ed..65d9370 100644 --- a/public/js/album-pages.js +++ b/public/js/album-pages.js @@ -295,7 +295,7 @@ function renderAlbumDetail(album) { var actions = ''; - if (!album) return '
未找到该相册
'; + if (!album) return ''; if (canEditContent) { actions = '
编辑相册' + @@ -327,8 +327,8 @@ function renderPhotoList(data) { var photos = Array.isArray(data) ? data.map(normalizeAlbumPhoto) : []; - if (photos.some(function (photo) { return !photo; })) return '
照片响应字段不完整
'; - if (!photos.length) return '
暂无照片
'; + if (photos.some(function (photo) { return !photo; })) return ''; + if (!photos.length) return ''; return photos.map(function (photo) { var action = canEditContent ? '
'; - if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '
当前没有家谱上下文。
'; - if (query('[data-album-photo-list]')) query('[data-album-photo-list]').innerHTML = '
当前没有家谱上下文。
'; + setStatus('[data-album-list]', message, 'empty'); + setStatus('[data-album-detail]', '当前没有家谱上下文。', 'empty'); + setStatus('[data-album-photo-list]', '当前没有家谱上下文。', 'empty'); setEditorEnabled(false, message); - setStatus('[data-album-form-status]', message); - setStatus('[data-album-photo-status]', message); + setStatus('[data-album-form-status]', message, 'forbidden'); + setStatus('[data-album-photo-status]', message, 'forbidden'); } function requireGenealogyId() { @@ -412,10 +425,14 @@ return genealogyId; } + function canEditAlbums(genealogy) { + return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true)); + } + async function loadCapability(api, genealogyId) { var detail = await api.genealogyDetail(genealogyId); - canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage)); + canEditContent = canEditAlbums(detail); queryAll('[data-album-create-link], [data-album-editor-action], [data-album-photo-editor]').forEach(function (element) { element.hidden = !canEditContent; }); @@ -430,11 +447,11 @@ albums.forEach(function (album) { albumsById[album.albumId] = album; }); if (!container) return albums; if (!Array.isArray(data) || (data.length && !albums.length)) { - container.innerHTML = '
相册响应缺少稳定 AlbumVo 字段,请联系后端核对。
'; + setStatus('[data-album-list]', '相册响应缺少稳定 AlbumVo 字段,请联系后端核对。', 'error'); return []; } if (!albums.length) { - container.innerHTML = '
暂无相册
'; + setStatus('[data-album-list]', '暂无相册', 'empty'); return []; } container.innerHTML = albums.map(renderAlbumRow).join(''); @@ -449,16 +466,17 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncLinks(); + setStatus('[data-album-list]', '正在加载相册…', 'loading'); try { await loadCapability(api, genealogyId); renderAlbums(await api.albums(genealogyId)); } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-album-list]')) { - query('[data-album-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱的相册。' : '相册加载失败,请稍后重试。') + - '
'; - } + setStatus( + '[data-album-list]', + isForbidden(error) ? '当前账号无权查看该家谱的相册。' : '相册加载失败,请稍后重试。', + getApiStateType(error) + ); showMessage(isForbidden(error) ? '当前账号无权查看该家谱的相册。' : (error.message || '相册加载失败')); } } @@ -498,6 +516,7 @@ albumId = getCurrentAlbumId(); syncLinks(); setEditorEnabled(false, '正在加载相册编辑信息…'); + setStatus('[data-album-form-status]', '正在读取家谱权限…', 'loading'); try { await loadCapability(api, genealogyId); if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的相册。' }; @@ -509,25 +528,38 @@ if (query('[data-album-editor-title]')) query('[data-album-editor-title]').textContent = '编辑相册'; } setEditorEnabled(true); + setStatus('[data-album-form-status]', ''); } catch (error) { if (redirectUnauthorized(api, error)) return; setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败')); - setStatus('[data-album-form-status]', isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败')); + setStatus( + '[data-album-form-status]', + isForbidden(error) ? '当前账号无权维护该家谱的相册。' : (error.message || '相册编辑信息加载失败'), + getApiStateType(error) + ); } } async function loadPhotos(api, genealogyId, albumId) { - var data = await api.albumPhotos(genealogyId, albumId); - var photos = normalizeAlbumPhotos(data); + var data; + var photos; var container = query('[data-album-photo-list]'); + setStatus('[data-album-photo-list]', '正在加载照片…', 'loading'); + data = await api.albumPhotos(genealogyId, albumId); + photos = normalizeAlbumPhotos(data); photosById = Object.create(null); photos.forEach(function (photo) { photosById[photo.photoId] = photo; }); - if (container) { - container.innerHTML = !Array.isArray(data) || (data.length && !photos.length) - ? '
照片响应缺少稳定 AlbumPhotoVo 字段,请联系后端核对。
' - : renderPhotoList(photos); + if (!container) return photos; + if (!Array.isArray(data) || (data.length && !photos.length)) { + setStatus('[data-album-photo-list]', '照片响应缺少稳定 AlbumPhotoVo 字段,请联系后端核对。', 'error'); + return []; } + if (!photos.length) { + setStatus('[data-album-photo-list]', '暂无照片', 'empty'); + return []; + } + container.innerHTML = renderPhotoList(photos); return photos; } @@ -541,10 +573,15 @@ genealogyId = requireGenealogyId(); albumId = getCurrentAlbumId(); if (!genealogyId || !albumId) { - if (genealogyId && query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '
缺少有效相册编号。
'; + if (genealogyId) { + setStatus('[data-album-detail]', '缺少有效相册编号。', 'error'); + setStatus('[data-album-photo-list]', '缺少有效相册编号。', 'empty'); + } return; } syncLinks(); + setStatus('[data-album-detail]', '正在加载相册信息…', 'loading'); + setStatus('[data-album-photo-list]', '正在加载照片…', 'loading'); try { await loadCapability(api, genealogyId); albums = await api.albums(genealogyId); @@ -552,12 +589,31 @@ if (!currentAlbum) throw new Error('相册列表未返回当前相册'); if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = renderAlbumDetail(currentAlbum); if (query('[data-album-detail-title]')) query('[data-album-detail-title]').textContent = currentAlbum.albumName; + } catch (error) { + if (redirectUnauthorized(api, error)) return; + setStatus( + '[data-album-detail]', + isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败'), + getApiStateType(error) + ); + setStatus( + '[data-album-photo-list]', + isForbidden(error) ? '当前账号无权查看该相册。' : '照片加载失败,请稍后重试。', + getApiStateType(error) + ); + showMessage(isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败')); + return; + } + try { await loadPhotos(api, genealogyId, albumId); } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-album-detail]')) query('[data-album-detail]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该相册。' : escapeHtml(error.message || '相册详情加载失败')) + '
'; - showMessage(isForbidden(error) ? '当前账号无权查看该相册。' : (error.message || '相册详情加载失败')); + setStatus( + '[data-album-photo-list]', + isForbidden(error) ? '当前账号无权查看该相册照片。' : (error.message || '照片加载失败'), + getApiStateType(error) + ); + showMessage(isForbidden(error) ? '当前账号无权查看该相册照片。' : (error.message || '照片加载失败')); } } @@ -572,17 +628,17 @@ var savedId; var verified; - if (writePending || !genealogyId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; body = buildAlbumBody(getFormValues(form)); validation = validateAlbumBody(body); if (validation) { - setStatus('[data-album-form-status]', validation); + setStatus('[data-album-form-status]', validation, 'error'); return; } delete body.invalidCoverOssId; delete body.invalidSortOrder; setWritePending(true); - setStatus('[data-album-form-status]', '正在保存相册…'); + setStatus('[data-album-form-status]', '正在保存相册…', 'loading'); try { result = albumId ? await api.updateAlbum(genealogyId, albumId, body) @@ -595,7 +651,11 @@ if (root.location) root.location.href = buildDetailUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus('[data-album-form-status]', isForbidden(error) ? '当前账号无权保存该相册。' : (error.message || '相册保存失败')); + setStatus( + '[data-album-form-status]', + isForbidden(error) ? '当前账号无权保存该相册。' : (error.message || '相册保存失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -609,17 +669,17 @@ var validation; var saved; - if (writePending || !genealogyId || !albumId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !albumId || redirectUnauthorized(api)) return; body = buildAlbumPhotoBody(getFormValues(form)); validation = validateAlbumPhotoBody(body); if (validation) { - setStatus('[data-album-photo-status]', validation); + setStatus('[data-album-photo-status]', validation, 'error'); return; } delete body.invalidOssId; delete body.invalidSortOrder; setWritePending(true); - setStatus('[data-album-photo-status]', '正在添加照片…'); + setStatus('[data-album-photo-status]', '正在添加照片…', 'loading'); try { saved = normalizeAlbumPhoto(await api.createAlbumPhoto(genealogyId, albumId, body)); if (!saved || saved.albumId !== albumId) throw new Error('添加照片响应缺少稳定 photoId'); @@ -630,7 +690,11 @@ setStatus('[data-album-photo-status]', '照片已添加'); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus('[data-album-photo-status]', isForbidden(error) ? '当前账号无权添加照片。' : (error.message || '照片添加失败')); + setStatus( + '[data-album-photo-status]', + isForbidden(error) ? '当前账号无权添加照片。' : (error.message || '照片添加失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -641,7 +705,7 @@ var genealogyId = requireGenealogyId(); var album = albumsById[albumId] || currentAlbum; - if (writePending || !genealogyId || !normalizeId(albumId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !normalizeId(albumId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (album ? album.albumName : '该相册') + '”及其中全部照片吗?删除后不可恢复。')) return; setWritePending(true); try { @@ -662,7 +726,7 @@ var albumId = getCurrentAlbumId(); var photo = photosById[photoId]; - if (writePending || !genealogyId || !albumId || !normalizeId(photoId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !albumId || !normalizeId(photoId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (photo && photo.photoTitle || '该照片') + '”吗?删除后不可恢复。')) return; setWritePending(true); try { @@ -722,6 +786,7 @@ validateAlbumBody: validateAlbumBody, buildAlbumPhotoBody: buildAlbumPhotoBody, validateAlbumPhotoBody: validateAlbumPhotoBody, + canEditAlbums: canEditAlbums, normalizeAlbum: normalizeAlbum, normalizeAlbumPhoto: normalizeAlbumPhoto, normalizeAlbums: normalizeAlbums, diff --git a/public/js/app-promotion-pages.js b/public/js/app-promotion-pages.js index 8cf085e..7b25405 100644 --- a/public/js/app-promotion-pages.js +++ b/public/js/app-promotion-pages.js @@ -86,12 +86,16 @@ function renderPromotionList(data) { var items = normalizePromotionList(data); - if (!items.length) return '
当前暂无应用推广
'; return items.map(renderPromotionCard).join(''); } - function renderPromotionLoginRequired() { - return '
登录后可查看应用推广,立即登录
'; + function setPromotionState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.textContent = message; } async function loadPromotions(api) { @@ -111,6 +115,10 @@ return Number(error && (error.status || error.code)) === 401; } + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + async function init() { var page = root.document && root.document.querySelector('[data-promotion-page]'); var list = root.document && root.document.querySelector('[data-promotion-list]'); @@ -118,22 +126,30 @@ if (!page || !list) return; if (!api || !api.getToken || !api.getToken()) { - list.innerHTML = renderPromotionLoginRequired(); + setPromotionState(list, 'forbidden', '登录后可查看应用推广。'); return; } + setPromotionState(list, 'loading', '正在读取应用推广…'); try { - list.classList.add('is-loading'); - list.innerHTML = renderPromotionList(await loadPromotions(api)); + var items = await loadPromotions(api); + + if (!items.length) { + setPromotionState(list, 'empty', '当前暂无应用推广。'); + return; + } + list.innerHTML = renderPromotionList(items); } catch (error) { if (isUnauthorized(error)) { if (api.clearToken) api.clearToken(); - list.innerHTML = renderPromotionLoginRequired(); + setPromotionState(list, 'forbidden', '登录后可查看应用推广。'); return; } - list.innerHTML = '
应用推广读取失败,请稍后重试
'; - } finally { - list.classList.remove('is-loading'); + setPromotionState( + list, + isForbidden(error) ? 'forbidden' : 'error', + isForbidden(error) ? '当前账号无权查看应用推广。' : '应用推广读取失败,请稍后重试。' + ); } } @@ -142,9 +158,10 @@ normalizePromotionList: normalizePromotionList, normalizeTargetUrl: normalizeTargetUrl, renderPromotionList: renderPromotionList, - renderPromotionLoginRequired: renderPromotionLoginRequired, + setPromotionState: setPromotionState, loadPromotions: loadPromotions, isUnauthorized: isUnauthorized, + isForbidden: isForbidden, init: init }; }); diff --git a/public/js/article-pages.js b/public/js/article-pages.js index db7b618..617c564 100644 --- a/public/js/article-pages.js +++ b/public/js/article-pages.js @@ -192,6 +192,10 @@ return articleId ? url + '&articleId=' + encodeURIComponent(articleId) : url; } + function canEditArticles(genealogy) { + return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true)); + } + function detailValue(label, value) { return '

' + escapeHtml(label) + '' + escapeHtml(value || '未提供') + '

'; } @@ -199,7 +203,7 @@ function renderArticleDetail(article) { var actions = ''; - if (!article) return '
请选择一篇谱文查看详情
'; + if (!article) return stateHtml('empty', '请选择一篇谱文查看详情'); if (canEditContent) { actions = '
编辑谱文' + @@ -256,17 +260,49 @@ else if (root.alert) root.alert(message); } + function stateHtml(type, message) { + if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message); + return '
' + escapeHtml(message) + '
'; + } + + function setState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.innerHTML = stateHtml(type, message); + } + + function getStateType(message) { + if (message.indexOf('无权') >= 0) return 'forbidden'; + if (/失败|缺少/.test(message)) return 'error'; + if (/正在|读取中|加载中/.test(message)) return 'loading'; + return 'empty'; + } + function syncLinks() { if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks(); } - function setFormStatus(message) { + function setFormStatus(message, type) { var target = query('[data-article-form-status]'); - if (target) target.textContent = message || ''; + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; } - function setEditorEnabled(enabled, message) { + function setEditorEnabled(enabled, message, type) { var placeholder = query('[data-article-editor-placeholder]'); queryAll('[data-article-editor], [data-article-editor-action]').forEach(function (element) { @@ -274,15 +310,22 @@ }); if (placeholder) { placeholder.hidden = Boolean(enabled); - if (message) placeholder.textContent = message; + if (message) setState(placeholder, type || getStateType(message), message); } } - function setWritePending(value) { + function setWritePending(value, label) { + var submit = query('[data-article-form] [type="submit"]'); + writePending = Boolean(value); queryAll('[data-article-form] button, [data-article-form] input, [data-article-form] textarea, [data-article-delete-id]').forEach(function (control) { control.disabled = writePending; }); + if (submit) { + if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent; + submit.textContent = writePending && label ? label : submit.dataset.defaultLabel; + submit.setAttribute('aria-busy', writePending ? 'true' : 'false'); + } } function renderNoContext() { @@ -290,10 +333,10 @@ var list = query('[data-article-list]'); var detail = query('[data-article-detail]'); - if (list) list.innerHTML = '
' + message + '
'; - if (detail) detail.innerHTML = '
当前没有家谱上下文。
'; - setEditorEnabled(false, message); - setFormStatus(message); + setState(list, 'empty', message); + setState(detail, 'empty', '当前没有家谱上下文。'); + setEditorEnabled(false, message, 'empty'); + setFormStatus(message, 'empty'); } function requireGenealogyId() { @@ -306,7 +349,7 @@ async function loadCapability(api, genealogyId) { var detail = await api.genealogyDetail(genealogyId); - canEditContent = Boolean(detail && (detail.canEditContent || detail.canManage)); + canEditContent = canEditArticles(detail); queryAll('[data-article-create-link], [data-article-editor-action]').forEach(function (element) { element.hidden = !canEditContent; }); @@ -323,11 +366,11 @@ }); if (!container) return articles; if (!Array.isArray(data) || (data.length && !articles.length)) { - container.innerHTML = '
谱文响应缺少稳定 ArticleVo 字段,请联系后端核对。
'; + setState(container, 'error', '谱文响应缺少稳定 ArticleVo 字段,请联系后端核对。'); return []; } if (!articles.length) { - container.innerHTML = '
暂无谱文
'; + setState(container, 'empty', '暂无谱文'); return []; } container.innerHTML = articles.map(renderArticleRow).join(''); @@ -346,6 +389,7 @@ var article; if (!genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return null; + setState(query('[data-article-detail]'), 'loading', '正在加载谱文详情…'); try { article = normalizeArticle(await api.articleDetail(genealogyId, articleId)); if (!article || article.articleId !== normalizeId(articleId)) { @@ -356,7 +400,8 @@ return article; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); + setState(query('[data-article-detail]'), isForbidden(error) ? 'forbidden' : 'error', + isForbidden(error) ? '当前账号无权查看该谱文。' : '谱文详情加载失败,请稍后重试。'); showMessage(isForbidden(error) ? '当前账号无权查看该谱文。' : (error.message || '谱文详情加载失败')); return null; } @@ -372,6 +417,7 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncLinks(); + setState(query('[data-article-list]'), 'loading', '正在加载谱文…'); try { await loadCapability(api, genealogyId); articles = renderArticles(await api.articles(genealogyId)); @@ -381,11 +427,8 @@ else renderDetail(null); } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-article-list]')) { - query('[data-article-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : '谱文加载失败,请稍后重试。') + - '
'; - } + setState(query('[data-article-list]'), isForbidden(error) ? 'forbidden' : 'error', + isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : '谱文加载失败,请稍后重试。'); showMessage(isForbidden(error) ? '当前账号无权查看该家谱的谱文。' : (error.message || '谱文加载失败')); } } @@ -438,7 +481,8 @@ if (!genealogyId) return; articleId = getCurrentArticleId(); syncLinks(); - setEditorEnabled(false, '正在加载谱文编辑信息…'); + setEditorEnabled(false, '正在加载谱文编辑信息…', 'loading'); + setFormStatus('正在读取家谱权限…', 'loading'); try { await loadCapability(api, genealogyId); if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的谱文。' }; @@ -449,12 +493,16 @@ if (query('[data-article-editor-title]')) query('[data-article-editor-title]').textContent = '编辑谱文'; } setEditorEnabled(true); + setFormStatus(''); } catch (error) { if (redirectUnauthorized(api, error)) return; setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的谱文。' - : (error.message || '谱文编辑信息加载失败')); - setFormStatus(isForbidden(error) ? '当前账号无权维护该家谱的谱文。' : (error.message || '谱文编辑信息加载失败')); + : (error.message || '谱文编辑信息加载失败'), isForbidden(error) ? 'forbidden' : 'error'); + setFormStatus( + isForbidden(error) ? '当前账号无权维护该家谱的谱文。' : (error.message || '谱文编辑信息加载失败'), + isForbidden(error) ? 'forbidden' : 'error' + ); } } @@ -468,18 +516,18 @@ var saved; var savedId; - if (writePending || !genealogyId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll(); body = buildArticleBody(getFormValues(form)); validation = validateArticleBody(body); if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } delete body.invalidCoverOssId; delete body.invalidSortOrder; - setWritePending(true); - setFormStatus('正在保存谱文…'); + setWritePending(true, '正在保存…'); + setFormStatus('正在保存谱文…', 'loading'); try { result = articleId ? await api.updateArticle(genealogyId, articleId, body) @@ -493,7 +541,10 @@ if (root.location) root.location.href = buildListUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setFormStatus(isForbidden(error) ? '当前账号无权保存该谱文。' : (error.message || '谱文保存失败')); + setFormStatus( + isForbidden(error) ? '当前账号无权保存该谱文。' : (error.message || '谱文保存失败'), + isForbidden(error) ? 'forbidden' : 'error' + ); } finally { setWritePending(false); } @@ -504,7 +555,7 @@ var genealogyId = requireGenealogyId(); var article = articlesById[articleId]; - if (writePending || !genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !normalizeId(articleId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (article ? article.articleTitle : '该谱文') + '”吗?删除后不可恢复。')) return; setWritePending(true); try { @@ -557,6 +608,7 @@ buildArticleBody: buildArticleBody, validateArticleBody: validateArticleBody, normalizeArticle: normalizeArticle, + canEditArticles: canEditArticles, normalizeArticles: normalizeArticles, matchesSavedArticle: matchesSavedArticle, renderArticleDetail: renderArticleDetail, diff --git a/public/js/auth-pages.js b/public/js/auth-pages.js index c19b477..bc33c04 100644 --- a/public/js/auth-pages.js +++ b/public/js/auth-pages.js @@ -229,6 +229,33 @@ button.setAttribute('aria-busy', busy ? 'true' : 'false'); } + function getApiStateType(error) { + return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error'; + } + + function setFormStatus(form, message, type) { + var status = query('[data-auth-form-status]', form); + + if (!status) return; + if (!message) { + status.innerHTML = ''; + return; + } + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(status, type || 'error', message); + return; + } + status.textContent = message; + } + + function setSubmitPending(button, pending, pendingLabel) { + if (!button) return; + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; + button.disabled = Boolean(pending); + button.textContent = pending ? pendingLabel : button.dataset.defaultLabel; + button.setAttribute('aria-busy', pending ? 'true' : 'false'); + } + function showMessage(message) { if (root.MessageUtil && root.MessageUtil.show) { root.MessageUtil.show(message); @@ -292,14 +319,6 @@ return ''; } - function validateBeforeSubmit(formType, values) { - var message = validateAuthValues(formType, values); - - if (!message) return true; - showMessage(message); - return false; - } - function validatePhoneOnly(values) { var data = values || {}; @@ -308,11 +327,13 @@ return ''; } - function validateBeforeSendCode(values) { - var message = validatePhoneOnly(values); + function validateForm(form, formType, values) { + var message = formType === 'send-code' + ? validatePhoneOnly(values) + : validateAuthValues(formType, values); if (!message) return true; - showMessage(message); + setFormStatus(form, message, 'error'); return false; } @@ -350,21 +371,25 @@ var values = readForm(form); if (!api) { - showMessage('接口初始化失败,请刷新后重试'); + setFormStatus(form, '接口初始化失败,请刷新后重试', 'error'); + return; + } + if (!validateForm(form, 'login', values)) return; + if (!await ensureCaptcha(form, getFormCaptchaOperation(form, 'login'), values.phone)) { + setFormStatus(form, '请完成验证后再提交登录。', 'error'); return; } - if (!validateBeforeSubmit('login', values)) return; - if (!await ensureCaptcha(form, getFormCaptchaOperation(form, 'login'), values.phone)) return; values = readForm(form); values.validToken = takeCaptchaToken(form); - setBusy(button, true); + setSubmitPending(button, true, '正在登录…'); + setFormStatus(form, '正在验证账号…', 'loading'); try { await api.login(buildLoginBody(values)); root.location.href = await resolvePostLoginUrl(api, 'login'); } catch (error) { - showMessage(error.message || '登录失败'); + setFormStatus(form, error.message || '登录失败', getApiStateType(error)); } finally { - setBusy(button, false); + setSubmitPending(button, false, '正在登录…'); } } @@ -374,18 +399,19 @@ var values = readForm(form); if (!api) { - showMessage('接口初始化失败,请刷新后重试'); + setFormStatus(form, '接口初始化失败,请刷新后重试', 'error'); return; } - if (!validateBeforeSubmit('sms-login', values)) return; - setBusy(button, true); + if (!validateForm(form, 'sms-login', values)) return; + setSubmitPending(button, true, '正在登录…'); + setFormStatus(form, '正在验证短信验证码…', 'loading'); try { await api.loginBySms(buildSmsLoginBody(values)); root.location.href = await resolvePostLoginUrl(api, 'sms-login'); } catch (error) { - showMessage(error.message || '短信登录失败'); + setFormStatus(form, error.message || '短信登录失败', getApiStateType(error)); } finally { - setBusy(button, false); + setSubmitPending(button, false, '正在登录…'); } } @@ -395,18 +421,19 @@ var values = readForm(form); if (!api) { - showMessage('接口初始化失败,请刷新后重试'); + setFormStatus(form, '接口初始化失败,请刷新后重试', 'error'); return; } - if (!validateBeforeSubmit('register', values)) return; - setBusy(button, true); + if (!validateForm(form, 'register', values)) return; + setSubmitPending(button, true, '正在注册…'); + setFormStatus(form, '正在创建账号…', 'loading'); try { await api.register(buildRegisterBody(values)); root.location.href = getSuccessUrl('register'); } catch (error) { - showMessage(error.message || '注册失败'); + setFormStatus(form, error.message || '注册失败', getApiStateType(error)); } finally { - setBusy(button, false); + setSubmitPending(button, false, '正在注册…'); } } @@ -419,26 +446,31 @@ var operationCode = getFormCaptchaOperation(form, formType); if (!api) { - showMessage('接口初始化失败,请刷新后重试'); + setFormStatus(form, '接口初始化失败,请刷新后重试', 'error'); + return; + } + if (!validateForm(form, 'send-code', values)) return; + if (!await ensureCaptcha(form, operationCode, values.phone)) { + setFormStatus(form, '请完成验证后再获取验证码。', 'error'); return; } - if (!validateBeforeSendCode(values)) return; - if (!await ensureCaptcha(form, operationCode, values.phone)) return; values = readForm(form); values.validToken = takeCaptchaToken(form); setBusy(button, true); + setFormStatus(form, '正在发送验证码…', 'loading'); try { await api.sendSmsCode(operationCode, { phone: values.phone, validToken: values.validToken || '' }); showMessage('验证码已发送'); + setFormStatus(form, ''); setBusy(button, false); startSmsCooldown(button); } catch (error) { - showMessage(error.message || '验证码发送失败'); + setFormStatus(form, error.message || '验证码发送失败', getApiStateType(error)); setBusy(button, false); } } @@ -449,19 +481,20 @@ var values = readForm(form); if (!api) { - showMessage('接口初始化失败,请刷新后重试'); + setFormStatus(form, '接口初始化失败,请刷新后重试', 'error'); return; } - if (!validateBeforeSubmit('password-reset', values)) return; - setBusy(button, true); + if (!validateForm(form, 'password-reset', values)) return; + setSubmitPending(button, true, '正在重设…'); + setFormStatus(form, '正在重设密码…', 'loading'); try { await api.resetPassword(buildPasswordResetBody(values)); showMessage('密码已重设,请重新登录'); root.location.href = getSuccessUrl('password-reset'); } catch (error) { - showMessage(error.message || '重设密码失败'); + setFormStatus(form, error.message || '重设密码失败', getApiStateType(error)); } finally { - setBusy(button, false); + setSubmitPending(button, false, '正在重设…'); } } diff --git a/public/js/ceremony-admin-pages.js b/public/js/ceremony-admin-pages.js index 8adf9ff..2021eb3 100644 --- a/public/js/ceremony-admin-pages.js +++ b/public/js/ceremony-admin-pages.js @@ -390,7 +390,7 @@ var mapLink = ''; var actions = ''; - if (!ceremony) return '
未找到该活动
'; + if (!ceremony) return ''; if (ceremony.longitude !== undefined && ceremony.latitude !== undefined) { mapLink = ' 祭品响应字段不完整
'; - if (!gifts.length) return '
暂无祭品记录
'; + if (gifts.some(function (gift) { return !gift; })) return ''; + if (!gifts.length) return ''; return gifts.map(function (gift) { var action = canEditContent ? '
'; if (record.lineagePersonName) summary.push(record.lineagePersonName); if (record.recordType) summary.push(record.recordType); if (record.recordDate) summary.push(record.recordDate); if (!summary.length) summary.push('未填写日期和关联人物'); + if (canEditContent) { + actions += '编辑'; + } return '

' + escapeHtml(record.recordTitle) + '

' + - escapeHtml(summary.join(' · ')) + '

' + - '' + - '编辑' + - '' + - '
'; + escapeHtml(summary.join(' · ')) + '

' + actions + '
'; } function detailValue(label, value) { @@ -274,9 +282,16 @@ function renderGrowthDetail(record) { var count; + var actions = ''; - if (!record) return '
请选择一条成长记录查看详情
'; + if (!record) return stateHtml('empty', '请选择一条成长记录查看详情'); count = attachmentCount(record.mediaOssIds); + if (canEditContent) { + actions = '
编辑记录' + + '
'; + } return '
编辑记录' + - '
'; + '' + actions; } function showMessage(message) { @@ -311,6 +324,10 @@ return Number(error && (error.status || error.code)) === 403; } + function getApiStateType(error) { + return isForbidden(error) ? 'forbidden' : 'error'; + } + function redirectUnauthorized(api, error) { if (!shouldRedirectToLogin(api, error)) return false; if (api && api.clearToken) api.clearToken(); @@ -327,13 +344,38 @@ if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks(); } - function setFormStatus(message) { - var status = query('[data-growth-form-status]'); - - if (status) status.textContent = message || ''; + function stateHtml(type, message) { + if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message); + return '
' + escapeHtml(message) + '
'; } - function setEditorEnabled(enabled, message) { + function setState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.innerHTML = stateHtml(type, message); + } + + function setFormStatus(message, type) { + var status = query('[data-growth-form-status]'); + + if (!status) return; + if (!message) { + status.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(status, type, message); + return; + } + status.textContent = message; + } + + function setEditorEnabled(enabled, message, type) { var placeholder = query('[data-growth-editor-placeholder]'); queryAll('[data-growth-editor], [data-growth-editor-action]').forEach(function (element) { @@ -341,19 +383,37 @@ }); if (placeholder) { placeholder.hidden = Boolean(enabled); - if (message) placeholder.textContent = message; + if (!enabled && message) { + if (type) setState(placeholder, type, message); + else placeholder.textContent = message; + } } } + function setEditAccess(enabled) { + canEditContent = Boolean(enabled); + queryAll('[data-growth-create-link]').forEach(function (element) { + element.hidden = !canEditContent; + }); + } + + async function loadCapability(api, genealogyId) { + var genealogy = await api.genealogyDetail(genealogyId); + + setEditAccess(canEditRecords(genealogy)); + return genealogy; + } + function renderNoContext() { var list = query('[data-growth-list]'); var detail = query('[data-growth-detail]'); var message = '请先选择家谱,再查看或维护成长记录。'; - if (list) list.innerHTML = '
' + message + '
'; - if (detail) detail.innerHTML = '
当前没有家谱上下文。
'; - setEditorEnabled(false, message); - setFormStatus(message); + setState(list, 'empty', message); + setState(detail, 'empty', '当前没有家谱上下文。'); + setEditAccess(false); + setEditorEnabled(false, message, 'empty'); + setFormStatus(message, 'empty'); } function requireGenealogyId() { @@ -363,11 +423,18 @@ return genealogyId; } - function setWritePending(pending) { + function setWritePending(pending, label) { + var submit = query('[data-growth-form] [type="submit"]'); + writePending = Boolean(pending); queryAll('[data-growth-form] input, [data-growth-form] textarea, [data-growth-form] select, [data-growth-form] button, [data-growth-delete-id]').forEach(function (control) { control.disabled = writePending; }); + if (submit) { + if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent; + submit.textContent = writePending && label ? label : submit.dataset.defaultLabel; + submit.setAttribute('aria-busy', writePending ? 'true' : 'false'); + } } function renderGrowthRecords(data) { @@ -382,11 +449,11 @@ }); if (!container) return records; if (!Array.isArray(data) || invalidCount) { - container.innerHTML = '
成长记录响应缺少稳定 GrowthRecordVo 字段,请联系后端核对。
'; + setState(container, 'error', '成长记录响应缺少稳定 GrowthRecordVo 字段,请联系后端核对。'); return []; } if (!records.length) { - container.innerHTML = '
暂无成长记录
'; + setState(container, 'empty', '暂无成长记录'); return []; } container.innerHTML = records.map(renderGrowthRow).join(''); @@ -405,6 +472,7 @@ var record; if (!genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return null; + setState(query('[data-growth-detail]'), 'loading', '正在加载成长记录详情…'); try { record = normalizeGrowthRecord(await api.growthRecordDetail(genealogyId, recordId)); if (!record) throw new Error('成长记录详情响应缺少稳定 GrowthRecordVo 字段'); @@ -413,7 +481,11 @@ return record; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); + setState( + query('[data-growth-detail]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该成长记录。' : '成长记录详情加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该成长记录。' : (error.message || '成长记录详情加载失败')); return null; } @@ -429,7 +501,9 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncGenealogyLinks(); + setState(query('[data-growth-list]'), 'loading', '正在加载成长记录…'); try { + await loadCapability(api, genealogyId); records = renderGrowthRecords(await api.growthRecords(genealogyId)); requestedRecordId = getCurrentRecordId(); if (requestedRecordId && recordsById[requestedRecordId]) { @@ -441,11 +515,12 @@ } } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-growth-list]')) { - query('[data-growth-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : '成长记录加载失败,请稍后重试。') + - '
'; - } + setEditAccess(false); + setState( + query('[data-growth-list]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : '成长记录加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该家谱的成长记录。' : (error.message || '成长记录加载失败')); } } @@ -510,8 +585,11 @@ if (!genealogyId) return; recordId = getCurrentRecordId(); syncGenealogyLinks(); - setEditorEnabled(false, '正在加载成长记录编辑信息…'); + setEditorEnabled(false, '正在加载成长记录编辑信息…', 'loading'); + setFormStatus('正在读取家谱权限…', 'loading'); try { + await loadCapability(api, genealogyId); + if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的成长记录。' }; result = await Promise.all([ api.lineagePersonOptions(genealogyId), recordId ? api.growthRecordDetail(genealogyId, recordId) : Promise.resolve(null) @@ -524,12 +602,17 @@ if (query('[data-growth-editor-title]')) query('[data-growth-editor-title]').textContent = '编辑成长记录'; } setEditorEnabled(true); + setFormStatus(''); } catch (error) { if (redirectUnauthorized(api, error)) return; + setEditAccess(false); setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的成长记录。' - : (error.message || '成长记录编辑信息加载失败')); - setFormStatus(error.message || '成长记录编辑信息加载失败'); + : (error.message || '成长记录编辑信息加载失败'), getApiStateType(error)); + setFormStatus( + isForbidden(error) ? '当前账号无权维护该家谱的成长记录。' : (error.message || '成长记录编辑信息加载失败'), + getApiStateType(error) + ); } } @@ -544,16 +627,16 @@ var savedId; var verifiedDetail; - if (writePending || !genealogyId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; body = buildGrowthRecordBody(getFormValues(form)); validation = validateGrowthRecordBody(body); if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } delete body.invalidSortOrder; - setWritePending(true); - setFormStatus('正在保存成长记录…'); + setWritePending(true, '正在保存…'); + setFormStatus('正在保存成长记录…', 'loading'); try { result = recordId ? await api.updateGrowthRecord(genealogyId, recordId, body) @@ -566,7 +649,10 @@ if (root.location) root.location.href = buildListUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setFormStatus(isForbidden(error) ? '当前账号无权保存该成长记录。' : (error.message || '成长记录保存失败')); + setFormStatus( + isForbidden(error) ? '当前账号无权保存该成长记录。' : (error.message || '成长记录保存失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -577,7 +663,7 @@ var genealogyId = requireGenealogyId(); var record = recordsById[recordId]; - if (writePending || !genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !normalizeId(recordId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (record ? record.recordTitle : '该成长记录') + '”吗?删除后不可恢复,并会释放附件引用。')) return; setWritePending(true); try { @@ -647,6 +733,7 @@ validateGrowthRecordBody: validateGrowthRecordBody, normalizeGrowthRecord: normalizeGrowthRecord, matchesSavedRecord: matchesSavedRecord, + canEditRecords: canEditRecords, renderLineageOptions: renderLineageOptions, renderGrowthDetail: renderGrowthDetail, shouldRedirectToLogin: shouldRedirectToLogin, diff --git a/public/js/help-pages.js b/public/js/help-pages.js index 41af41c..41dd746 100644 --- a/public/js/help-pages.js +++ b/public/js/help-pages.js @@ -133,6 +133,10 @@ else root.location.href = 'login.html'; } + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + async function init() { var list = root.document && root.document.querySelector('[data-help-list]'); var status = root.document && root.document.querySelector('[data-help-status]'); @@ -145,23 +149,48 @@ return; } - function setStatus(message) { - if (status) status.textContent = message || ''; + function setApiState(target, type, message) { + if (!target) return; + if (root.ProfileUI && root.ProfileUI.setApiState && type) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message || ''; + } + + function setStatus(type, message) { + setApiState(status, type, message); + } + + function setHelpList(items) { + if (!items.length) { + setApiState(list, 'empty', '当前暂无帮助文章。'); + return; + } + list.innerHTML = renderHelpArticleList(items); + } + + function setPendingButton(button, pending, label) { + if (!button) return; + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; + button.disabled = pending; + button.textContent = pending ? label : button.dataset.defaultLabel; } async function refreshList() { try { list.classList.add('is-loading'); - setStatus('正在读取帮助文章…'); - list.innerHTML = renderHelpArticleList(await loadHelpArticles(api)); - setStatus(''); + setApiState(list, 'loading', '正在读取帮助文章…'); + setStatus('loading', '正在读取帮助文章…'); + setHelpList(await loadHelpArticles(api)); + setStatus('', ''); } catch (error) { if (shouldRedirectToLogin(api, error)) { redirectToLogin(api); return; } - list.innerHTML = '
帮助文章读取失败,请稍后重试
'; - setStatus(error.message || '帮助文章读取失败'); + setApiState(list, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看帮助文章。' : '帮助文章读取失败,请稍后重试。'); + setStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看帮助文章。' : (error.message || '帮助文章读取失败,请稍后重试。')); } finally { list.classList.remove('is-loading'); } @@ -177,7 +206,7 @@ panel = list.querySelector('[data-help-detail-panel="' + helpId + '"]'); if (!panel) return; button.disabled = true; - panel.textContent = '正在读取完整解答…'; + setApiState(panel, 'loading', '正在读取完整解答…'); try { panel.innerHTML = renderHelpArticleDetail(await loadHelpArticleDetail(api, helpId)); } catch (error) { @@ -185,11 +214,18 @@ redirectToLogin(api); return; } - panel.textContent = error.message || '帮助文章详情读取失败'; + setApiState(panel, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该帮助文章。' : (error.message || '帮助文章详情读取失败,请稍后重试。')); } }); - if (refresh) refresh.addEventListener('click', refreshList); + if (refresh) refresh.addEventListener('click', async function () { + setPendingButton(refresh, true, '正在刷新…'); + try { + await refreshList(); + } finally { + setPendingButton(refresh, false, '正在刷新…'); + } + }); await refreshList(); } @@ -200,6 +236,7 @@ renderHelpArticleDetail: renderHelpArticleDetail, loadHelpArticleDetail: loadHelpArticleDetail, loadHelpArticles: loadHelpArticles, + isForbidden: isForbidden, shouldRedirectToLogin: shouldRedirectToLogin, init: init }; diff --git a/public/js/join-pages.js b/public/js/join-pages.js index 9f46ce4..957353f 100644 --- a/public/js/join-pages.js +++ b/public/js/join-pages.js @@ -29,6 +29,14 @@ .replace(/'/g, '''); } + function renderApiState(type, message) { + if (root.ProfileUI && root.ProfileUI.renderApiState) { + return root.ProfileUI.renderApiState(type, message); + } + return '
' + + escapeHtml(message) + '
'; + } + function normalizeId(value) { var text; @@ -129,9 +137,9 @@ function renderJoinGenealogyOptions(data) { var items; - if (!Array.isArray(data)) return '
家谱列表数据无效,请刷新重试
'; + if (!Array.isArray(data)) return renderApiState('error', '家谱列表数据无效,请刷新重试'); items = data.map(normalizeJoinGenealogy).filter(Boolean); - if (!items.length) return '
没有找到可申请加入的家谱
'; + if (!items.length) return renderApiState('empty', '没有找到可申请加入的家谱'); return items.map(function (item) { var meta = [ item.surname + '氏', @@ -150,7 +158,7 @@ function renderMyJoinApplies(data) { var items = normalizeJoinApplies(data); - if (!items.length) return '
当前没有加入申请
'; + if (!items.length) return renderApiState('empty', '当前没有加入申请'); return items.map(function (item) { var details = [ item.surname ? item.surname + '氏' : '', @@ -184,10 +192,40 @@ else root.location.href = 'login.html'; } - function setText(selector, message) { + function getApiStateType(error) { + return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error'; + } + + function setText(selector, message, type) { var element = root.document && root.document.querySelector(selector); - if (element) element.textContent = message || ''; + if (!element) return; + if (!message) { + element.innerHTML = ''; + return; + } + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(element, type || 'error', message); + return; + } + element.textContent = message; + } + + function setOptionsState(options, type, message) { + if (!options) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(options, type, message); + return; + } + options.innerHTML = renderApiState(type, message); + } + + function setSubmitPending(button, pending, pendingLabel) { + if (!button) return; + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; + button.disabled = Boolean(pending); + button.textContent = pending ? pendingLabel : button.dataset.defaultLabel; + button.setAttribute('aria-busy', pending ? 'true' : 'false'); } function getFormValues(form) { @@ -222,27 +260,34 @@ return; } + function clearSelectedGenealogy() { + selectedGenealogyId = ''; + if (selected) selected.textContent = '尚未选择家谱'; + } + async function loadOptions(keyword) { var data; - setText('[data-join-apply-status]', '正在读取可申请家谱…'); + clearSelectedGenealogy(); + setText('[data-join-options-status]', '正在读取可申请家谱…', 'loading'); + setOptionsState(options, 'loading', '正在读取可申请家谱…'); try { data = await api.genealogyOptions(keyword ? { keyword: keyword } : {}); options.innerHTML = renderJoinGenealogyOptions(data); - setText('[data-join-apply-status]', ''); + setText('[data-join-options-status]', ''); } catch (error) { if (shouldRedirectToLogin(api, error)) { redirectToLogin(api); return; } - options.innerHTML = '
读取家谱列表失败
'; - setText('[data-join-apply-status]', error.message || '读取家谱列表失败,请稍后重试'); + setOptionsState(options, getApiStateType(error), '读取家谱列表失败'); + setText('[data-join-options-status]', error.message || '读取家谱列表失败,请稍后重试', getApiStateType(error)); } } try { if (!canJoinGenealogy(await api.genealogyQuota())) { - setText('[data-join-apply-status]', '当前没有可用的家谱加入额度'); + setText('[data-join-apply-status]', '当前没有可用的家谱加入额度', 'empty'); applyForm.querySelector('[data-join-apply-submit]').disabled = true; return; } @@ -251,7 +296,7 @@ redirectToLogin(api); return; } - setText('[data-join-apply-status]', error.message || '无法读取家谱加入额度'); + setText('[data-join-apply-status]', error.message || '无法读取家谱加入额度', getApiStateType(error)); applyForm.querySelector('[data-join-apply-submit]').disabled = true; return; } @@ -280,18 +325,18 @@ event.preventDefault(); if (writePending) return; if (!selectedGenealogyId) { - setText('[data-join-apply-status]', '请先从家谱列表中选择要加入的家谱'); + setText('[data-join-apply-status]', '请先从家谱列表中选择要加入的家谱', 'error'); return; } body = buildJoinApplyBody(getFormValues(applyForm)); validation = validateJoinApplyBody(body); if (validation) { - setText('[data-join-apply-status]', validation); + setText('[data-join-apply-status]', validation, 'error'); return; } writePending = true; - applyForm.querySelector('[data-join-apply-submit]').disabled = true; - setText('[data-join-apply-status]', '正在提交申请…'); + setSubmitPending(applyForm.querySelector('[data-join-apply-submit]'), true, '正在提交…'); + setText('[data-join-apply-status]', '正在提交申请…', 'loading'); try { created = normalizeJoinApply(await api.applyToGenealogy(selectedGenealogyId, body)); if (!created) throw new Error('申请响应缺少有效申请信息'); @@ -305,10 +350,10 @@ redirectToLogin(api); return; } - setText('[data-join-apply-status]', error.message || '提交加入申请失败,请稍后重试'); + setText('[data-join-apply-status]', error.message || '提交加入申请失败,请稍后重试', getApiStateType(error)); } finally { writePending = false; - applyForm.querySelector('[data-join-apply-submit]').disabled = false; + setSubmitPending(applyForm.querySelector('[data-join-apply-submit]'), false, '正在提交…'); } }); await loadOptions(''); @@ -330,7 +375,7 @@ async function loadApplies() { var data; - setText('[data-my-join-applies-status]', '正在读取申请记录…'); + setText('[data-my-join-applies-status]', '正在读取申请记录…', 'loading'); try { data = await api.myGenealogyJoinApplies(); list.innerHTML = renderMyJoinApplies(data); @@ -341,8 +386,8 @@ redirectToLogin(api); return []; } - list.innerHTML = '
读取加入申请失败
'; - setText('[data-my-join-applies-status]', error.message || '读取加入申请失败,请稍后重试'); + list.innerHTML = renderApiState(getApiStateType(error), '读取加入申请失败'); + setText('[data-my-join-applies-status]', error.message || '读取加入申请失败,请稍后重试', getApiStateType(error)); return []; } } @@ -361,7 +406,7 @@ if (root.confirm && !root.confirm('确认撤销这条加入申请吗?')) return; writePending = true; button.disabled = true; - setText('[data-my-join-applies-status]', '正在撤销申请…'); + setText('[data-my-join-applies-status]', '正在撤销申请…', 'loading'); try { await api.cancelGenealogyJoinApply(applyId); refreshed = await loadApplies(); @@ -373,7 +418,7 @@ redirectToLogin(api); return; } - setText('[data-my-join-applies-status]', error.message || '撤销加入申请失败,请稍后重试'); + setText('[data-my-join-applies-status]', error.message || '撤销加入申请失败,请稍后重试', getApiStateType(error)); } finally { writePending = false; } diff --git a/public/js/join-review-pages.js b/public/js/join-review-pages.js index 4e171a2..b07253d 100644 --- a/public/js/join-review-pages.js +++ b/public/js/join-review-pages.js @@ -91,7 +91,7 @@ function renderPendingJoinApplies(data, canManage) { var items = normalizePendingApplies(data); - if (!items.length) return '
当前没有待审核申请
'; + if (!items.length) return ''; return items.map(function (item) { var details = [ item.phone ? '申请联系电话:' + item.phone : '', @@ -124,16 +124,103 @@ else root.location.href = 'login.html'; } - function setStatus(message) { + function getApiStateType(error) { + return Number(error && (error.status || error.code)) === 403 ? 'forbidden' : 'error'; + } + + function setStatus(message, type) { var element = root.document && root.document.querySelector('[data-join-review-status]'); - if (element) element.textContent = message || ''; + if (!element) return; + if (!message) { + element.innerHTML = ''; + return; + } + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(element, type || 'error', message); + return; + } + element.textContent = message; + } + + function setReviewFormStatus(message, type) { + var element = root.document && root.document.querySelector('[data-join-review-form-status]'); + + if (!element) return; + if (!message) { + element.innerHTML = ''; + return; + } + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(element, type || 'error', message); + return; + } + element.textContent = message; + } + + function setPendingListState(message, type) { + var element = root.document && root.document.querySelector('[data-join-apply-list="pending"]'); + + if (!element) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(element, type || 'error', message); + return; + } + element.textContent = message; + } + + function setReviewFormEnabled(enabled) { + var form = root.document && root.document.querySelector('[data-join-review-form]'); + + if (!form) return; + Array.prototype.slice.call(form.querySelectorAll('textarea, button')).forEach(function (field) { + field.disabled = !enabled; + }); + } + + function setReviewSubmitPending(pending) { + var button = root.document && root.document.querySelector('[data-join-review-submit]'); + + if (!button) return; + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; + button.textContent = pending ? '正在提交…' : button.dataset.defaultLabel; + button.setAttribute('aria-busy', pending ? 'true' : 'false'); + } + + function openReviewForm(applyId, status) { + var form = root.document && root.document.querySelector('[data-join-review-form]'); + var panel = root.document && root.document.querySelector('[data-join-review-editor]'); + var title = root.document && root.document.querySelector('[data-join-review-form-title]'); + var submit = root.document && root.document.querySelector('[data-join-review-submit]'); + + if (!form || !panel || !applyId || ['1', '2'].indexOf(status) < 0) return; + form.reset(); + form.querySelector('[name="applyId"]').value = applyId; + form.querySelector('[name="status"]').value = status; + if (title) title.textContent = status === '1' ? '通过加入申请' : '拒绝加入申请'; + if (submit) submit.textContent = status === '1' ? '确认通过' : '确认拒绝'; + setReviewFormStatus(''); + panel.hidden = false; + if (root.location && root.location.hash !== '#join-review-form') root.location.hash = 'join-review-form'; + if (form.querySelector('[name="auditRemark"]') && form.querySelector('[name="auditRemark"]').focus) { + form.querySelector('[name="auditRemark"]').focus(); + } + } + + function closeReviewForm() { + var form = root.document && root.document.querySelector('[data-join-review-form]'); + var panel = root.document && root.document.querySelector('[data-join-review-editor]'); + + if (form) form.reset(); + if (panel) panel.hidden = true; + setReviewFormStatus(''); } async function initJoinReviewPage() { var page = root.document && root.document.querySelector('[data-join-review-page]'); var list = root.document && root.document.querySelector('[data-join-apply-list="pending"]'); var refresh = root.document && root.document.querySelector('[data-join-review-refresh]'); + var form = root.document && root.document.querySelector('[data-join-review-form]'); var api = root.GenealogyApi && root.GenealogyApi.defaultClient; var genealogyId = getCurrentGenealogyId(); var canManage = false; @@ -141,7 +228,8 @@ if (!page) return; if (!genealogyId) { - setStatus('请先从“我的家谱”选择要管理的家谱'); + setStatus('请先从“我的家谱”选择要管理的家谱', 'forbidden'); + setReviewFormEnabled(false); list.innerHTML = '选择家谱后继续'; return; } @@ -156,19 +244,32 @@ async function loadPending() { var data; - setStatus('正在读取待审核申请…'); + setStatus('正在读取待审核申请…', 'loading'); try { data = await api.pendingGenealogyJoinApplies(genealogyId); + if (!Array.isArray(data) || (data.length && !normalizePendingApplies(data).length)) { + setPendingListState('待审核申请响应字段不完整,请联系后端核对。', 'error'); + setStatus('待审核申请响应字段不完整,请联系后端核对。', 'error'); + return []; + } + if (!data.length) { + setPendingListState('当前没有待审核申请', 'empty'); + setStatus(''); + return []; + } list.innerHTML = renderPendingJoinApplies(data, canManage); - setStatus(canManage ? '' : '当前账号没有审核该家谱申请的权限'); + setStatus(canManage ? '' : '当前账号没有审核该家谱申请的权限', canManage ? undefined : 'forbidden'); return normalizePendingApplies(data); } catch (error) { if (shouldRedirectToLogin(api, error)) { redirectToLogin(api); return []; } - list.innerHTML = '
读取待审核申请失败
'; - setStatus(error.message || '读取待审核申请失败,请稍后重试'); + setPendingListState( + getApiStateType(error) === 'forbidden' ? '当前账号没有审核该家谱申请的权限。' : '读取待审核申请失败,请稍后重试。', + getApiStateType(error) + ); + setStatus(error.message || '读取待审核申请失败,请稍后重试', getApiStateType(error)); return []; } } @@ -178,8 +279,9 @@ canManage = detail && detail.canManage === true; if (!canManage) { - list.innerHTML = '
当前账号没有审核该家谱申请的权限
'; - setStatus('当前账号没有审核该家谱申请的权限'); + setPendingListState('当前账号没有审核该家谱申请的权限。', 'forbidden'); + setStatus('当前账号没有审核该家谱申请的权限', 'forbidden'); + setReviewFormEnabled(false); return; } } catch (error) { @@ -187,55 +289,90 @@ redirectToLogin(api); return; } - setStatus(error.message || '无法核验当前家谱管理权限'); + setStatus(error.message || '无法核验当前家谱管理权限', getApiStateType(error)); + setPendingListState( + getApiStateType(error) === 'forbidden' ? '当前账号没有审核该家谱申请的权限。' : '无法读取待审核申请,请稍后重试。', + getApiStateType(error) + ); + setReviewFormEnabled(false); return; } + setReviewFormEnabled(true); + refresh.addEventListener('click', function () { loadPending(); }); - list.addEventListener('click', async function (event) { + list.addEventListener('click', function (event) { var button = event.target.closest('[data-join-audit-id]'); var applyId; var status; - var auditRemark = ''; - var body; - var validation; - var refreshed; if (!button || !list.contains(button) || writePending || !canManage) return; applyId = normalizeId(button.getAttribute('data-join-audit-id')); status = button.getAttribute('data-join-audit-status'); if (!applyId || ['1', '2'].indexOf(status) < 0) return; - if (status === '1' && root.confirm && !root.confirm('确认通过这条加入申请吗?')) return; - if (status === '2' && root.prompt) { - auditRemark = root.prompt('可填写拒绝原因(最多 500 个字符)', '') || ''; + openReviewForm(applyId, status); + }); + form.addEventListener('submit', async function (event) { + var values; + var applyId; + var body; + var validation; + var refreshed; + var forbidden = false; + + event.preventDefault(); + if (writePending || !canManage) return; + values = {}; + Array.prototype.slice.call(form.querySelectorAll('[name]')).forEach(function (field) { + values[field.name] = field.value; + }); + applyId = normalizeId(values.applyId); + body = buildJoinAuditBody(values); + if (!applyId) { + setReviewFormStatus('申请编号无效,请返回列表重新操作。', 'error'); + return; } - body = buildJoinAuditBody({ status: status, auditRemark: auditRemark }); validation = validateJoinAuditBody(body); if (validation) { - setStatus(validation); + setReviewFormStatus(validation, 'error'); return; } writePending = true; - button.disabled = true; - setStatus('正在提交审核结果…'); + setReviewFormEnabled(false); + setReviewSubmitPending(true); + setReviewFormStatus('正在提交审核结果…', 'loading'); try { await api.auditGenealogyJoinApply(genealogyId, applyId, body); refreshed = await loadPending(); if (refreshed.some(function (item) { return item.applyId === applyId; })) { throw new Error('审核后申请仍在待审核列表'); } + closeReviewForm(); } catch (error) { if (shouldRedirectToLogin(api, error)) { redirectToLogin(api); return; } - setStatus(error.message || '提交审核结果失败,请稍后重试'); + forbidden = getApiStateType(error) === 'forbidden'; + setReviewFormStatus( + forbidden ? '当前账号无权审核该家谱申请。' : (error.message || '提交审核结果失败,请稍后重试。'), + getApiStateType(error) + ); + if (forbidden) { + canManage = false; + setStatus('当前账号无权审核该家谱申请。', 'forbidden'); + } } finally { writePending = false; + setReviewSubmitPending(false); + if (!forbidden) setReviewFormEnabled(true); } }); + root.document.querySelector('[data-join-review-cancel]').addEventListener('click', function () { + if (!writePending) closeReviewForm(); + }); await loadPending(); } diff --git a/public/js/lineage-pages.js b/public/js/lineage-pages.js index 9e3f7e0..4b956f2 100644 --- a/public/js/lineage-pages.js +++ b/public/js/lineage-pages.js @@ -304,11 +304,11 @@ }); if (!container) return; if (invalidCount) { - container.innerHTML = '
人物响应缺少可安全使用的 personId 或 name,请联系后端补充 DTO。
'; + setLineageState('[data-lineage-list]', 'error', '人物响应缺少可安全使用的 personId 或 name,请联系后端补充 DTO。'); return; } if (!people.length) { - container.innerHTML = '
暂无世系人物
'; + setLineageState('[data-lineage-list]', 'empty', '暂无世系人物'); return; } container.innerHTML = people.map(function (person) { @@ -344,16 +344,20 @@ function renderLineageTreeHtml(data) { var nodes = normalizeList(data).map(function (item) { return renderTreeNode(item, {}); }).filter(Boolean); - if (!nodes.length) { - return '
暂无世系树
'; - } + if (!nodes.length) return ''; return ''; } function renderTree(data) { var container = query('[data-lineage-tree]'); + var html = renderLineageTreeHtml(data); - if (container) container.innerHTML = renderLineageTreeHtml(data); + if (!container) return; + if (!html) { + setLineageState('[data-lineage-tree]', 'empty', '暂无世系树'); + return; + } + container.innerHTML = html; } function renderOptions(data) { @@ -397,9 +401,11 @@ function updateBindingFields() { var bindingMode = query('[name="bindingMode"]'); var memberSelect = query('[data-lineage-member-options]'); + var specifiedMember = query('[data-lineage-specified-member]'); var specified = Boolean(bindingMode && bindingMode.value === 'SPECIFIED'); if (!memberSelect) return; + if (specifiedMember) specifiedMember.hidden = !specified; memberSelect.hidden = !specified; memberSelect.required = specified; if (!specified) memberSelect.value = ''; @@ -469,7 +475,7 @@ if (!container) return; if (!person) { - container.innerHTML = '
人物详情缺少可安全使用的 personId 或 name,请联系后端补充 DTO。
'; + setLineageState('[data-lineage-detail]', 'error', '人物详情缺少可安全使用的 personId 或 name,请联系后端补充 DTO。'); return; } selectedPersonId = person.personId; @@ -513,10 +519,61 @@ return values; } - function setFormStatus(message) { + function setLineageState(selector, type, message) { + var target = query(selector); + + if (!target) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.innerHTML = '
' + + escapeHtml(message) + '
'; + } + + function setFormStatus(message, type) { var target = query('[data-lineage-form-status]'); - if (target) target.textContent = message || ''; + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; + } + + function setActionStatus(message, type) { + var target = query('[data-lineage-action-status]'); + + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; + } + + function setLineageSubmitPending(pending) { + queryAll('[data-lineage-submit]').forEach(function (button) { + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; + button.textContent = pending ? '正在保存…' : button.dataset.defaultLabel; + button.setAttribute('aria-busy', pending ? 'true' : 'false'); + }); + } + + function setLineageSubmitLabel(label) { + queryAll('[data-lineage-submit]').forEach(function (button) { + button.dataset.defaultLabel = label; + if (!writePending) button.textContent = label; + }); } function relationLabel(mode) { @@ -545,13 +602,12 @@ function renderNoContext() { ['[data-lineage-tree]', '[data-lineage-list]', '[data-lineage-detail]'].forEach(function (selector) { - var container = query(selector); - - if (container) container.innerHTML = '
请从具体家谱进入世系管理
'; + setLineageState(selector, 'error', '请从具体家谱进入世系管理'); }); if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = ''; currentPageTotal = null; setLineageEnabled(false); + setActionStatus('请从具体家谱进入世系管理。', 'error'); updatePaginationControls(); } @@ -588,18 +644,18 @@ function setWritePending(pending) { writePending = Boolean(pending); refreshControls(); + setLineageSubmitPending(writePending); } function renderForbiddenState() { ['[data-lineage-tree]', '[data-lineage-list]', '[data-lineage-detail]'].forEach(function (selector) { - var container = query(selector); - - if (container) container.innerHTML = '
当前账号没有世系人物管理权限
'; + setLineageState(selector, 'forbidden', '当前账号没有世系人物管理权限'); }); if (query('[data-lineage-list-summary]')) query('[data-lineage-list-summary]').textContent = ''; currentPageTotal = null; setLineageEnabled(false); - setFormStatus('当前账号没有世系人物管理权限'); + setFormStatus('当前账号没有世系人物管理权限', 'forbidden'); + setActionStatus('当前账号没有世系人物管理权限', 'forbidden'); } function getCurrentKeyword() { @@ -648,6 +704,9 @@ if (!genealogyId) return; currentPageNum = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1; syncGenealogyLinks(genealogyId); + setLineageState('[data-lineage-tree]', 'loading', '正在加载世系树…'); + setLineageState('[data-lineage-list]', 'loading', '正在加载世系人物…'); + setActionStatus(''); try { results = await Promise.all([ api.genealogyDetail(genealogyId), @@ -681,9 +740,7 @@ return; } ['[data-lineage-tree]', '[data-lineage-list]'].forEach(function (selector) { - var container = query(selector); - - if (container) container.innerHTML = '
世系人物加载失败,请稍后重试
'; + setLineageState(selector, 'error', '世系人物加载失败,请稍后重试'); }); showMessage(error.message || '世系人物加载失败'); } @@ -696,6 +753,7 @@ if (redirectUnauthorized(api)) return; genealogyId = requireGenealogyId(); if (!genealogyId || !personId) return; + setLineageState('[data-lineage-detail]', 'loading', '正在加载成员详情…'); try { renderDetail(await api.lineagePersonDetail(genealogyId, personId)); } catch (error) { @@ -704,6 +762,7 @@ renderForbiddenState(); return; } + setLineageState('[data-lineage-detail]', 'error', '人物详情加载失败,请稍后重试。'); showMessage(error.message || '人物详情加载失败'); } } @@ -720,7 +779,7 @@ relationMode = mode; editingPersonId = ''; setRelationFieldVisibility(mode === 'spouses'); - if (submitButton) submitButton.textContent = '新增' + relationLabel(mode); + if (submitButton) setLineageSubmitLabel('新增' + relationLabel(mode)); setFormStatus('当前将为“' + person.name + '”新增' + relationLabel(mode)); } @@ -777,7 +836,7 @@ editingPersonId = person.personId; setRelationFieldVisibility(false); submitButton = query('[data-lineage-form] button[type="submit"]'); - if (submitButton) submitButton.textContent = '保存修改'; + if (submitButton) setLineageSubmitLabel('保存修改'); setFormStatus('正在编辑“' + person.name + '”'); } @@ -787,7 +846,7 @@ relationMode = ''; editingPersonId = ''; setRelationFieldVisibility(false); - if (submitButton) submitButton.textContent = '保存成员'; + if (submitButton) setLineageSubmitLabel('保存成员'); setFormStatus(''); if (root.setTimeout) root.setTimeout(updateBindingFields, 0); } @@ -803,11 +862,11 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } setWritePending(true); - setFormStatus('正在保存成员...'); + setFormStatus('正在保存成员…', 'loading'); try { if (editingPersonId) { result = await api.updateLineagePerson(genealogyId, editingPersonId, body); @@ -833,7 +892,7 @@ renderForbiddenState(); return; } - setFormStatus(error.message || '成员保存失败'); + setFormStatus(error.message || '成员保存失败', 'error'); } finally { setWritePending(false); } @@ -849,6 +908,7 @@ if (!genealogyId || !personId) return; if (root.confirm && !root.confirm('确认停用“' + (person ? person.name : '该成员') + '”吗?有正常子女时后端会拒绝停用。')) return; setWritePending(true); + setActionStatus('正在停用成员…', 'loading'); try { await api.disableLineagePerson(genealogyId, personId); selectedPersonId = ''; @@ -861,6 +921,7 @@ renderForbiddenState(); return; } + setActionStatus(error.message || '成员停用失败', 'error'); showMessage(error.message || '成员停用失败'); } finally { setWritePending(false); diff --git a/public/js/member-admin-pages.js b/public/js/member-admin-pages.js index 0d50105..5583f06 100644 --- a/public/js/member-admin-pages.js +++ b/public/js/member-admin-pages.js @@ -133,9 +133,7 @@ var source = values || {}; var body = {}; var memberName = trimOrUndefined(source.memberName); - var relationName = source.relationName === undefined || source.relationName === null - ? undefined - : stringValue(source.relationName).trim(); + var relationName = trimOrUndefined(source.relationName); var roleType = trimOrUndefined(source.roleType); var lineagePersonId = normalizeId(source.lineagePersonId); @@ -175,8 +173,8 @@ return Boolean(role && role !== 'owner'); } - function canTransferOwner(role) { - return role === 'owner'; + function canTransferOwner(role, canManage) { + return role === 'owner' && canManage === true; } function buildOwnerTransferBody(targetMemberId) { @@ -246,10 +244,39 @@ else if (root.alert) root.alert(message); } - function setStatus(selector, message) { + function setStatus(selector, message, type) { var target = query(selector); - if (target) target.textContent = message || ''; + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; + } + + function setMemberListState(type, message) { + var target = query('[data-member-list]'); + + if (!target) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.innerHTML = '
' + + escapeHtml(message) + '
'; + } + + function setPendingButton(selector, pending, pendingLabel) { + queryAll(selector).forEach(function (button) { + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; + button.textContent = pending ? pendingLabel : button.dataset.defaultLabel; + button.setAttribute('aria-busy', pending ? 'true' : 'false'); + }); } function syncLinks() { @@ -261,6 +288,9 @@ queryAll('[data-member-form] input, [data-member-form] select, [data-member-form] button, [data-owner-transfer-form] select, [data-owner-transfer-form] button, [data-member-edit-id], [data-member-remove-id], [data-member-leave]').forEach(function (control) { control.disabled = writePending; }); + setPendingButton('[data-member-submit]', writePending, '正在保存…'); + setPendingButton('[data-owner-transfer-submit]', writePending, '正在转让…'); + setPendingButton('[data-member-leave]', writePending, '正在退出…'); } function renderLineageOptions(data) { @@ -299,16 +329,21 @@ members.forEach(function (member) { membersById[member.memberId] = member; }); if (!container) return members; if (!Array.isArray(data) || (data.length && !members.length)) { - container.innerHTML = '
成员响应缺少稳定 GenealogyMemberVo 字段,请联系后端核对。
'; + setMemberListState('error', '成员响应缺少稳定 GenealogyMemberVo 字段,请联系后端核对。'); return []; } - container.innerHTML = members.length ? members.map(function (member) { + if (!members.length) { + setMemberListState('empty', '暂无正常成员'); + renderTransferOptions(members); + return members; + } + container.innerHTML = members.map(function (member) { return renderMemberRow(member, { actorRole: actorRole, currentMemberId: currentMemberId, canManage: managementAccess }); - }).join('') : '
暂无正常成员
'; + }).join(''); renderTransferOptions(members); return members; } @@ -358,8 +393,9 @@ } function renderNoContext() { - if (query('[data-member-list]')) query('[data-member-list]').innerHTML = '
请先选择家谱,再管理成员。
'; - setStatus('[data-member-overview]', '当前没有家谱上下文。'); + setMemberListState('forbidden', '请先选择家谱,再管理成员。'); + setStatus('[data-member-overview]', '当前没有家谱上下文。', 'forbidden'); + setStatus('[data-member-action-status]', '请先选择家谱,再进行成员操作。', 'forbidden'); queryAll('[data-member-management], [data-member-leave-section], [data-owner-transfer-section]').forEach(function (element) { element.hidden = true; }); @@ -381,6 +417,9 @@ return; } syncLinks(); + setStatus('[data-member-overview]', '正在加载家谱和当前权限…', 'loading'); + setMemberListState('loading', '正在加载成员…'); + setStatus('[data-member-action-status]', ''); try { results = await Promise.all([ api.genealogyDetail(genealogyId), @@ -399,13 +438,13 @@ setStatus('[data-member-overview]', (detail.genealogyName || '当前家谱') + ' · ' + members.length + ' 位正常成员 · 当前角色:' + (roleLabels[actorRole] || '成员')); queryAll('[data-member-management]').forEach(function (element) { element.hidden = !managementAccess; }); queryAll('[data-member-leave-section]').forEach(function (element) { element.hidden = !currentMember || !canLeaveGenealogy(actorRole); }); - queryAll('[data-owner-transfer-section]').forEach(function (element) { element.hidden = !currentMember || !canTransferOwner(actorRole); }); + queryAll('[data-owner-transfer-section]').forEach(function (element) { element.hidden = !currentMember || !canTransferOwner(actorRole, managementAccess); }); renderLineageOptions(results[3]); renderMembers(results[2]); } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-member-list]')) query('[data-member-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。') + '
'; + setMemberListState(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。'); + setStatus('[data-member-overview]', isForbidden(error) ? '当前账号无权查看该家谱成员。' : '成员加载失败,请稍后重试。', isForbidden(error) ? 'forbidden' : 'error'); showMessage(isForbidden(error) ? '当前账号无权查看该家谱成员。' : (error.message || '成员加载失败')); } } @@ -429,12 +468,12 @@ body = buildMemberUpdateBody(getFormValues(form)); validation = validateMemberUpdateBody(body); if (validation) { - setStatus('[data-member-form-status]', validation); + setStatus('[data-member-form-status]', validation, 'error'); return; } delete body.invalidLineagePersonId; setWritePending(true); - setStatus('[data-member-form-status]', '正在保存成员…'); + setStatus('[data-member-form-status]', '正在保存成员…', 'loading'); try { saved = normalizeMember(await api.updateGenealogyMember(genealogyId, selectedMemberId, body)); if (!saved || saved.memberId !== selectedMemberId) throw new Error('成员修改响应缺少稳定 memberId'); @@ -445,7 +484,7 @@ showMessage('成员信息已更新'); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus('[data-member-form-status]', isForbidden(error) ? '当前账号无权修改该成员。' : (error.message || '成员修改失败')); + setStatus('[data-member-form-status]', isForbidden(error) ? '当前账号无权修改该成员。' : (error.message || '成员修改失败'), isForbidden(error) ? 'forbidden' : 'error'); } finally { setWritePending(false); } @@ -459,6 +498,7 @@ if (writePending || !genealogyId || !target || !managementAccess || !canRemoveMember(actorRole, target.roleType) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认将“' + memberDisplayName(target) + '”移出当前家谱吗?该操作不会删除其世系人物资料。')) return; setWritePending(true); + setStatus('[data-member-action-status]', '正在移出成员…', 'loading'); try { await api.removeGenealogyMember(genealogyId, memberId); if ((await fetchAndRenderMembers(api, genealogyId)).some(function (member) { return member.memberId === memberId; })) { @@ -468,6 +508,7 @@ showMessage('成员已移出家谱'); } catch (error) { if (redirectUnauthorized(api, error)) return; + setStatus('[data-member-action-status]', isForbidden(error) ? '当前账号无权移出该成员。' : (error.message || '移出成员失败'), isForbidden(error) ? 'forbidden' : 'error'); showMessage(isForbidden(error) ? '当前账号无权移出该成员。' : (error.message || '移出成员失败')); } finally { setWritePending(false); @@ -481,11 +522,13 @@ if (writePending || !genealogyId || !currentMemberId || !canLeaveGenealogy(actorRole) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认退出当前家谱吗?退出后需重新受邀才能进入。世系人物资料不会被删除。')) return; setWritePending(true); + setStatus('[data-member-action-status]', '正在退出当前家谱…', 'loading'); try { await api.leaveGenealogy(genealogyId); if (root.location) root.location.href = 'profile-families.html'; } catch (error) { if (redirectUnauthorized(api, error)) return; + setStatus('[data-member-action-status]', isForbidden(error) ? '当前账号无权退出该家谱。' : (error.message || '退出家谱失败'), isForbidden(error) ? 'forbidden' : 'error'); showMessage(isForbidden(error) ? '当前账号无权退出该家谱。' : (error.message || '退出家谱失败')); } finally { setWritePending(false); @@ -499,21 +542,21 @@ var validation = validateOwnerTransferBody(body, currentMemberId); var target = membersById[body.targetMemberId]; - if (writePending || !genealogyId || !canTransferOwner(actorRole) || redirectUnauthorized(api)) return; + if (writePending || !genealogyId || !canTransferOwner(actorRole, managementAccess) || redirectUnauthorized(api)) return; if (validation || !target) { - setStatus('[data-owner-transfer-status]', validation || '请选择成员列表中的新谱主'); + setStatus('[data-owner-transfer-status]', validation || '请选择成员列表中的新谱主', 'error'); return; } if (root.confirm && !root.confirm('确认将谱主转让给“' + memberDisplayName(target) + '”吗?转让后当前账号将变为管理员。')) return; setWritePending(true); - setStatus('[data-owner-transfer-status]', '正在转让谱主…'); + setStatus('[data-owner-transfer-status]', '正在转让谱主…', 'loading'); try { await api.transferGenealogyOwner(genealogyId, body); await loadPage(); setStatus('[data-owner-transfer-status]', '谱主已转让'); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus('[data-owner-transfer-status]', isForbidden(error) ? '只有当前谱主可以执行转让。' : (error.message || '谱主转让失败')); + setStatus('[data-owner-transfer-status]', isForbidden(error) ? '只有当前谱主可以执行转让。' : (error.message || '谱主转让失败'), isForbidden(error) ? 'forbidden' : 'error'); } finally { setWritePending(false); } diff --git a/public/js/memo-pages.js b/public/js/memo-pages.js index 4a9d297..60faadc 100644 --- a/public/js/memo-pages.js +++ b/public/js/memo-pages.js @@ -15,6 +15,7 @@ var documentRef = root.document; var memosById = Object.create(null); + var canEditContent = false; var writePending = false; function getApi() { @@ -228,18 +229,25 @@ return memoId ? url + '&memoId=' + encodeURIComponent(memoId) : url; } + function canEditRecords(genealogy) { + return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true)); + } + function renderMemoRow(memo) { var summary = []; + var actions = ''; summary.push(memo.completed === '1' ? '已完成' : '未完成'); if (memo.remindTime) summary.push('提醒:' + memo.remindTime); if (memo.memoContent) summary.push(memo.memoContent); + if (canEditContent) { + actions += '编辑'; + } return '

' + escapeHtml(memo.memoTitle) + '

' + - escapeHtml(summary.join(' · ')) + '

' + - '' + - '编辑' + - '' + - '
'; + escapeHtml(summary.join(' · ')) + '

' + actions + '
'; } function detailValue(label, value) { @@ -248,9 +256,16 @@ function renderMemoDetail(memo) { var count; + var actions = ''; - if (!memo) return '
请选择一条备忘录查看详情
'; + if (!memo) return stateHtml('empty', '请选择一条备忘录查看详情'); count = attachmentCount(memo.mediaOssIds); + if (canEditContent) { + actions = '
编辑备忘录' + + '
'; + } return '
编辑备忘录' + - '
'; + '' + actions; } function showMessage(message) { @@ -279,6 +292,10 @@ return Number(error && (error.status || error.code)) === 403; } + function getApiStateType(error) { + return isForbidden(error) ? 'forbidden' : 'error'; + } + function redirectUnauthorized(api, error) { if (!shouldRedirectToLogin(api, error)) return false; if (api && api.clearToken) api.clearToken(); @@ -288,13 +305,38 @@ return true; } - function setFormStatus(message) { - var target = query('[data-memo-form-status]'); - - if (target) target.textContent = message || ''; + function stateHtml(type, message) { + if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message); + return '
' + escapeHtml(message) + '
'; } - function setEditorEnabled(enabled, message) { + function setState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.innerHTML = stateHtml(type, message); + } + + function setFormStatus(message, type) { + var target = query('[data-memo-form-status]'); + + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; + } + + function setEditorEnabled(enabled, message, type) { var placeholder = query('[data-memo-editor-placeholder]'); queryAll('[data-memo-editor], [data-memo-editor-action]').forEach(function (element) { @@ -302,15 +344,39 @@ }); if (placeholder) { placeholder.hidden = Boolean(enabled); - if (message) placeholder.textContent = message; + if (!enabled && message) { + if (type) setState(placeholder, type, message); + else placeholder.textContent = message; + } } } - function setWritePending(value) { + function setEditAccess(enabled) { + canEditContent = Boolean(enabled); + queryAll('[data-memo-create-link]').forEach(function (element) { + element.hidden = !canEditContent; + }); + } + + async function loadCapability(api, genealogyId) { + var genealogy = await api.genealogyDetail(genealogyId); + + setEditAccess(canEditRecords(genealogy)); + return genealogy; + } + + function setWritePending(value, label) { + var submit = query('[data-memo-form] [type="submit"]'); + writePending = Boolean(value); queryAll('[data-memo-form] button, [data-memo-form] input, [data-memo-form] textarea, [data-memo-form] select, [data-memo-delete-id]').forEach(function (control) { control.disabled = writePending; }); + if (submit) { + if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent; + submit.textContent = writePending && label ? label : submit.dataset.defaultLabel; + submit.setAttribute('aria-busy', writePending ? 'true' : 'false'); + } } function syncLinks() { @@ -322,10 +388,11 @@ var detail = query('[data-memo-detail]'); var message = '请先选择家谱,再查看或维护备忘录。'; - if (list) list.innerHTML = '
' + message + '
'; - if (detail) detail.innerHTML = '
当前没有家谱上下文。
'; - setEditorEnabled(false, message); - setFormStatus(message); + setState(list, 'empty', message); + setState(detail, 'empty', '当前没有家谱上下文。'); + setEditAccess(false); + setEditorEnabled(false, message, 'empty'); + setFormStatus(message, 'empty'); } function requireGenealogyId() { @@ -347,11 +414,11 @@ }); if (!container) return memos; if (!Array.isArray(data) || invalidCount) { - container.innerHTML = '
备忘录响应缺少稳定 MemoVo 字段,请联系后端核对。
'; + setState(container, 'error', '备忘录响应缺少稳定 MemoVo 字段,请联系后端核对。'); return []; } if (!memos.length) { - container.innerHTML = '
暂无备忘录
'; + setState(container, 'empty', '暂无备忘录'); return []; } container.innerHTML = memos.map(renderMemoRow).join(''); @@ -370,6 +437,7 @@ var memo; if (!genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return null; + setState(query('[data-memo-detail]'), 'loading', '正在加载备忘录详情…'); try { memo = normalizeMemo(await api.memoDetail(genealogyId, memoId)); if (!memo) throw new Error('备忘录详情响应缺少稳定 MemoVo 字段'); @@ -378,7 +446,11 @@ return memo; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); + setState( + query('[data-memo-detail]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该备忘录。' : '备忘录详情加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该备忘录。' : (error.message || '备忘录详情加载失败')); return null; } @@ -394,7 +466,9 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncLinks(); + setState(query('[data-memo-list]'), 'loading', '正在加载备忘录…'); try { + await loadCapability(api, genealogyId); memos = renderMemos(await api.memos(genealogyId)); requestedMemoId = getCurrentMemoId(); if (requestedMemoId && memosById[requestedMemoId]) { @@ -406,11 +480,12 @@ } } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-memo-list]')) { - query('[data-memo-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : '备忘录加载失败,请稍后重试。') + - '
'; - } + setEditAccess(false); + setState( + query('[data-memo-list]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : '备忘录加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该家谱的备忘录。' : (error.message || '备忘录加载失败')); } } @@ -457,8 +532,11 @@ if (!genealogyId) return; memoId = getCurrentMemoId(); syncLinks(); - setEditorEnabled(false, '正在加载备忘录编辑信息…'); + setEditorEnabled(false, '正在加载备忘录编辑信息…', 'loading'); + setFormStatus('正在读取家谱权限…', 'loading'); try { + await loadCapability(api, genealogyId); + if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的备忘录。' }; if (memoId) { memo = normalizeMemo(await api.memoDetail(genealogyId, memoId)); if (!memo) throw new Error('备忘录详情响应缺少稳定 MemoVo 字段'); @@ -466,12 +544,17 @@ if (query('[data-memo-editor-title]')) query('[data-memo-editor-title]').textContent = '编辑备忘录'; } setEditorEnabled(true); + setFormStatus(''); } catch (error) { if (redirectUnauthorized(api, error)) return; + setEditAccess(false); setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的备忘录。' - : (error.message || '备忘录编辑信息加载失败')); - setFormStatus(error.message || '备忘录编辑信息加载失败'); + : (error.message || '备忘录编辑信息加载失败'), getApiStateType(error)); + setFormStatus( + isForbidden(error) ? '当前账号无权维护该家谱的备忘录。' : (error.message || '备忘录编辑信息加载失败'), + getApiStateType(error) + ); } } @@ -486,16 +569,16 @@ var savedId; var verifiedDetail; - if (writePending || !genealogyId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; body = buildMemoBody(getFormValues(form)); validation = validateMemoBody(body); if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } delete body.invalidSortOrder; - setWritePending(true); - setFormStatus('正在保存备忘录…'); + setWritePending(true, '正在保存…'); + setFormStatus('正在保存备忘录…', 'loading'); try { result = memoId ? await api.updateMemo(genealogyId, memoId, body) @@ -508,7 +591,10 @@ if (root.location) root.location.href = buildListUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setFormStatus(isForbidden(error) ? '当前账号无权保存该备忘录。' : (error.message || '备忘录保存失败')); + setFormStatus( + isForbidden(error) ? '当前账号无权保存该备忘录。' : (error.message || '备忘录保存失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -519,7 +605,7 @@ var genealogyId = requireGenealogyId(); var memo = memosById[memoId]; - if (writePending || !genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !normalizeId(memoId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (memo ? memo.memoTitle : '该备忘录') + '”吗?删除后不可恢复,并会释放附件引用。')) return; setWritePending(true); try { @@ -589,6 +675,7 @@ validateMemoBody: validateMemoBody, normalizeMemo: normalizeMemo, matchesSavedMemo: matchesSavedMemo, + canEditRecords: canEditRecords, isEditableMemo: isEditableMemo, renderMemoDetail: renderMemoDetail, shouldRedirectToLogin: shouldRedirectToLogin, diff --git a/public/js/merit-pages.js b/public/js/merit-pages.js index ec10346..714db23 100644 --- a/public/js/merit-pages.js +++ b/public/js/merit-pages.js @@ -15,6 +15,7 @@ var documentRef = root.document; var recordsById = Object.create(null); + var canEditContent = false; var writePending = false; var meritTypes = { donation: '捐赠', @@ -284,17 +285,24 @@ return meritId ? url + '&meritId=' + encodeURIComponent(meritId) : url; } + function canEditRecords(genealogy) { + return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true)); + } + function renderMeritRow(record) { var summary = [meritTypes[record.meritType]]; + var actions = ''; if (record.meritTime) summary.push(record.meritTime); if (record.amount) summary.push('金额:' + record.amount); + if (canEditContent) { + actions += '编辑'; + } return '

' + escapeHtml(record.meritTitle) + '

' + - escapeHtml(record.donorName + ' · ' + summary.join(' · ')) + '

' + - '' + - '编辑' + - '' + - '
'; + escapeHtml(record.donorName + ' · ' + summary.join(' · ')) + '

' + actions + '
'; } function detailValue(label, value) { @@ -302,7 +310,15 @@ } function renderMeritDetail(record) { - if (!record) return '
请选择一条功德记录查看详情
'; + var actions = ''; + + if (!record) return stateHtml('empty', '请选择一条功德记录查看详情'); + if (canEditContent) { + actions = '
编辑功德记录' + + '
'; + } return '
编辑功德记录' + - '
'; + '' + actions; } function showMessage(message) { @@ -332,6 +346,10 @@ return Number(error && (error.status || error.code)) === 403; } + function getApiStateType(error) { + return isForbidden(error) ? 'forbidden' : 'error'; + } + function redirectUnauthorized(api, error) { if (!shouldRedirectToLogin(api, error)) return false; if (api && api.clearToken) api.clearToken(); @@ -341,13 +359,38 @@ return true; } - function setFormStatus(message) { - var target = query('[data-merit-form-status]'); - - if (target) target.textContent = message || ''; + function stateHtml(type, message) { + if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message); + return '
' + escapeHtml(message) + '
'; } - function setEditorEnabled(enabled, message) { + function setState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.innerHTML = stateHtml(type, message); + } + + function setFormStatus(message, type) { + var target = query('[data-merit-form-status]'); + + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; + } + + function setEditorEnabled(enabled, message, type) { var placeholder = query('[data-merit-editor-placeholder]'); queryAll('[data-merit-editor], [data-merit-editor-action]').forEach(function (element) { @@ -355,15 +398,39 @@ }); if (placeholder) { placeholder.hidden = Boolean(enabled); - if (message) placeholder.textContent = message; + if (!enabled && message) { + if (type) setState(placeholder, type, message); + else placeholder.textContent = message; + } } } - function setWritePending(value) { + function setEditAccess(enabled) { + canEditContent = Boolean(enabled); + queryAll('[data-merit-create-link]').forEach(function (element) { + element.hidden = !canEditContent; + }); + } + + async function loadCapability(api, genealogyId) { + var genealogy = await api.genealogyDetail(genealogyId); + + setEditAccess(canEditRecords(genealogy)); + return genealogy; + } + + function setWritePending(value, label) { + var submit = query('[data-merit-form] [type="submit"]'); + writePending = Boolean(value); queryAll('[data-merit-form] button, [data-merit-form] input, [data-merit-form] textarea, [data-merit-form] select, [data-merit-delete-id]').forEach(function (control) { control.disabled = writePending; }); + if (submit) { + if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent; + submit.textContent = writePending && label ? label : submit.dataset.defaultLabel; + submit.setAttribute('aria-busy', writePending ? 'true' : 'false'); + } } function syncLinks() { @@ -375,10 +442,11 @@ var detail = query('[data-merit-detail]'); var message = '请先选择家谱,再查看或维护功德记录。'; - if (list) list.innerHTML = '
' + message + '
'; - if (detail) detail.innerHTML = '
当前没有家谱上下文。
'; - setEditorEnabled(false, message); - setFormStatus(message); + setState(list, 'empty', message); + setState(detail, 'empty', '当前没有家谱上下文。'); + setEditAccess(false); + setEditorEnabled(false, message, 'empty'); + setFormStatus(message, 'empty'); } function requireGenealogyId() { @@ -400,11 +468,11 @@ }); if (!container) return records; if (!Array.isArray(data) || invalidCount) { - container.innerHTML = '
功德记录响应缺少稳定 MeritRecordVo 字段,请联系后端核对。
'; + setState(container, 'error', '功德记录响应缺少稳定 MeritRecordVo 字段,请联系后端核对。'); return []; } if (!records.length) { - container.innerHTML = '
暂无功德记录
'; + setState(container, 'empty', '暂无功德记录'); return []; } container.innerHTML = records.map(renderMeritRow).join(''); @@ -423,6 +491,7 @@ var record; if (!genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return null; + setState(query('[data-merit-detail]'), 'loading', '正在加载功德记录详情…'); try { record = normalizeMeritRecord(await api.meritRecordDetail(genealogyId, meritId)); if (!record) throw new Error('功德记录详情响应缺少稳定 MeritRecordVo 字段'); @@ -431,7 +500,11 @@ return record; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); + setState( + query('[data-merit-detail]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该功德记录。' : '功德记录详情加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该功德记录。' : (error.message || '功德记录详情加载失败')); return null; } @@ -447,7 +520,9 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncLinks(); + setState(query('[data-merit-list]'), 'loading', '正在加载功德记录…'); try { + await loadCapability(api, genealogyId); records = renderMeritRecords(await api.meritRecords(genealogyId)); requestedMeritId = getCurrentMeritId(); if (requestedMeritId && recordsById[requestedMeritId]) { @@ -459,11 +534,12 @@ } } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-merit-list]')) { - query('[data-merit-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : '功德记录加载失败,请稍后重试。') + - '
'; - } + setEditAccess(false); + setState( + query('[data-merit-list]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : '功德记录加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该家谱的功德记录。' : (error.message || '功德记录加载失败')); } } @@ -517,8 +593,11 @@ if (!genealogyId) return; meritId = getCurrentMeritId(); syncLinks(); - setEditorEnabled(false, '正在加载功德记录编辑信息…'); + setEditorEnabled(false, '正在加载功德记录编辑信息…', 'loading'); + setFormStatus('正在读取家谱权限…', 'loading'); try { + await loadCapability(api, genealogyId); + if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的功德记录。' }; if (meritId) { record = normalizeMeritRecord(await api.meritRecordDetail(genealogyId, meritId)); if (!record) throw new Error('功德记录详情响应缺少稳定 MeritRecordVo 字段'); @@ -526,12 +605,17 @@ if (query('[data-merit-editor-title]')) query('[data-merit-editor-title]').textContent = '编辑功德记录'; } setEditorEnabled(true); + setFormStatus(''); } catch (error) { if (redirectUnauthorized(api, error)) return; + setEditAccess(false); setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的功德记录。' - : (error.message || '功德记录编辑信息加载失败')); - setFormStatus(error.message || '功德记录编辑信息加载失败'); + : (error.message || '功德记录编辑信息加载失败'), getApiStateType(error)); + setFormStatus( + isForbidden(error) ? '当前账号无权维护该家谱的功德记录。' : (error.message || '功德记录编辑信息加载失败'), + getApiStateType(error) + ); } } @@ -546,18 +630,19 @@ var savedId; var verifiedDetail; - if (writePending || !genealogyId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; + if (root.AppRichEditor && root.AppRichEditor.syncAll) root.AppRichEditor.syncAll(); body = buildMeritRecordBody(getFormValues(form)); validation = validateMeritRecordBody(body); if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } delete body.invalidAmount; delete body.invalidAmountPrecision; delete body.invalidSortOrder; - setWritePending(true); - setFormStatus('正在保存功德记录…'); + setWritePending(true, '正在保存…'); + setFormStatus('正在保存功德记录…', 'loading'); try { result = meritId ? await api.updateMeritRecord(genealogyId, meritId, body) @@ -570,7 +655,10 @@ if (root.location) root.location.href = buildListUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setFormStatus(isForbidden(error) ? '当前账号无权保存该功德记录。' : (error.message || '功德记录保存失败')); + setFormStatus( + isForbidden(error) ? '当前账号无权保存该功德记录。' : (error.message || '功德记录保存失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -581,7 +669,7 @@ var genealogyId = requireGenealogyId(); var record = recordsById[meritId]; - if (writePending || !genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !normalizeId(meritId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (record ? record.meritTitle : '该功德记录') + '”吗?删除后不可恢复。')) return; setWritePending(true); try { @@ -635,6 +723,7 @@ validateMeritRecordBody: validateMeritRecordBody, normalizeMeritRecord: normalizeMeritRecord, matchesSavedMerit: matchesSavedMerit, + canEditRecords: canEditRecords, isEditableMerit: isEditableMerit, renderMeritDetail: renderMeritDetail, shouldRedirectToLogin: shouldRedirectToLogin, diff --git a/public/js/notification-pages.js b/public/js/notification-pages.js index eaec193..6f82b4f 100644 --- a/public/js/notification-pages.js +++ b/public/js/notification-pages.js @@ -178,10 +178,23 @@ return true; } - function updateStatus(message) { + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + + function setNotificationState(target, type, message) { + if (!target) return; + if (root.ProfileUI && root.ProfileUI.setApiState && type) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message || ''; + } + + function updateStatus(type, message) { var status = query('[data-notification-status]'); - if (status) status.textContent = message || ''; + setNotificationState(status, type, message); } function setWritePending(value) { @@ -203,21 +216,27 @@ }); if (!container) return notifications; if (!Array.isArray(data) || invalidCount) { - container.innerHTML = '
通知列表响应缺少稳定 NotificationView 字段,请联系后端核对。
'; + setNotificationState(container, 'error', '通知列表响应无效,请稍后重试。'); return []; } if (!notifications.length) { - container.innerHTML = '
暂无通知
'; + setNotificationState(container, 'empty', '当前暂无通知。'); return []; } container.innerHTML = notifications.map(renderNotificationRow).join(''); + if (writePending) setWritePending(true); return notifications; } function renderDetail(notification) { var container = query('[data-notification-detail]'); - if (container) container.innerHTML = renderNotificationDetail(notification); + if (!container) return; + if (!notification) { + setNotificationState(container, 'empty', '请选择一条通知查看详情。'); + return; + } + container.innerHTML = renderNotificationDetail(notification); } async function loadNotificationDetail(notificationId) { @@ -225,6 +244,7 @@ var notification; if (!normalizeId(notificationId) || redirectUnauthorized(api)) return null; + setNotificationState(query('[data-notification-detail]'), 'loading', '正在读取通知详情…'); try { notification = normalizeNotification(await api.notificationDetail(notificationId)); if (!notification || !matchesNotification(notification, notificationId)) { @@ -236,8 +256,8 @@ return notification; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); - updateStatus(error.message || '通知详情加载失败'); + setNotificationState(query('[data-notification-detail]'), isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该通知详情。' : '通知详情加载失败,请稍后重试。'); + updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看该通知详情。' : (error.message || '通知详情加载失败,请稍后重试。')); return null; } } @@ -257,10 +277,12 @@ if (redirectUnauthorized(api)) return; if (!api.notifications || !api.notificationDetail || !api.unreadNotificationCount) { - updateStatus('消息接口初始化失败,请刷新后重试'); + setNotificationState(query('[data-notification-list]'), 'error', '消息服务初始化失败,请刷新后重试。'); + updateStatus('error', '消息服务初始化失败,请刷新后重试。'); return; } - updateStatus('正在同步消息…'); + setNotificationState(query('[data-notification-list]'), 'loading', '正在同步消息…'); + updateStatus('loading', '正在同步消息…'); try { result = await Promise.all([ api.notifications(buildNotificationQuery(currentFilter())), @@ -273,12 +295,12 @@ : (notifications[0] && notifications[0].notificationId); if (nextSelectedId) await loadNotificationDetail(nextSelectedId); else renderDetail(null); - updateStatus('消息已同步'); + updateStatus('', '消息已同步'); } catch (error) { if (redirectUnauthorized(api, error)) return; renderNotifications(null); - renderDetail(null); - updateStatus(error.message || '消息同步失败'); + setNotificationState(query('[data-notification-detail]'), isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看通知详情。' : '消息同步失败,请稍后重试。'); + updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限查看通知列表。' : (error.message || '消息同步失败,请稍后重试。')); } } @@ -288,6 +310,7 @@ if (writePending || !normalizeId(notificationId) || !canMarkRead(notification) || redirectUnauthorized(api)) return; setWritePending(true); + updateStatus('loading', '正在标记通知为已读…'); try { await api.markNotificationRead(notificationId); selectedNotificationId = notificationId; @@ -295,7 +318,7 @@ await loadNotifications(); } catch (error) { if (redirectUnauthorized(api, error)) return; - updateStatus(error.message || '标记通知已读失败'); + updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限标记该通知为已读。' : (error.message || '标记通知已读失败,请稍后重试。')); } finally { setWritePending(false); } @@ -306,17 +329,18 @@ if (writePending || redirectUnauthorized(api)) return; if (!api.markAllNotificationsRead) { - updateStatus('消息接口初始化失败,请刷新后重试'); + updateStatus('error', '消息服务初始化失败,请刷新后重试。'); return; } setWritePending(true); + updateStatus('loading', '正在将全部通知标记为已读…'); try { await api.markAllNotificationsRead(); showMessage('已全部标记为已读'); await loadNotifications(); } catch (error) { if (redirectUnauthorized(api, error)) return; - updateStatus(error.message || '全部标记已读失败'); + updateStatus(isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '暂无权限标记全部通知为已读。' : (error.message || '全部标记已读失败,请稍后重试。')); } finally { setWritePending(false); } @@ -368,6 +392,7 @@ renderNotificationRow: renderNotificationRow, renderNotificationDetail: renderNotificationDetail, shouldRedirectToLogin: shouldRedirectToLogin, + isForbidden: isForbidden, loadNotifications: loadNotifications, init: init }; diff --git a/public/js/profile-common.js b/public/js/profile-common.js index 5cc514b..78f09f9 100644 --- a/public/js/profile-common.js +++ b/public/js/profile-common.js @@ -1,12 +1,43 @@ (function (window, $) { 'use strict'; - if (!$) return; - + var profileUI = window.ProfileUI || {}; var layuiReady = false; var layerApi = null; var genealogyContextStorageKey = 'genealogy_current_context_v1'; var mobileMenuId = 'profile-mobile-menu'; + var apiStateTypes = ['loading', 'empty', 'error', 'forbidden']; + + function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderApiState(type, message) { + var role; + var busy; + + if (apiStateTypes.indexOf(type) < 0) throw new TypeError('不支持的业务状态:' + type); + role = type === 'error' || type === 'forbidden' ? 'alert' : 'status'; + busy = type === 'loading' ? ' aria-busy="true"' : ''; + return '
' + escapeHtml(message) + '
'; + } + + function setApiState(element, type, message) { + if (!element) return; + element.innerHTML = renderApiState(type, message); + } + + profileUI.renderApiState = renderApiState; + profileUI.setApiState = setApiState; + window.ProfileUI = profileUI; + + if (!$) return; function getSessionStorage() { try { @@ -231,13 +262,25 @@ if (!value || value.charAt(0) === '#' || /^(?:https?:|mailto:|tel:)/i.test(value)) return value; if (value.indexOf('profile-families.html') === 0) return value; - return 'profile-families.html?next=' + encodeURIComponent(value.split('?')[0]); + return 'profile-families.html?next=' + encodeURIComponent(value); + } + + var genealogyContextPagePattern = /(?:^|\/)profile-(?:admin-permissions|album(?:-detail|-edit)?|article(?:-edit)?|ceremony(?:-detail)?|content|family-admin|family-home|feed(?:-detail|-edit)?|generation|gift(?:-edit)?|growth(?:-edit)?|join-review|memo(?:-edit)?|merit(?:-edit)?|relative(?:-edit)?|tree|video(?:-edit)?)\.html(?:[?#]|$)/; + + function isGenealogyContextHref(href) { + var value = String(href || ''); + + return !/^(?:https?:|mailto:|tel:)/i.test(value) && genealogyContextPagePattern.test(value); } function syncGenealogyContextLinks() { var genealogyId = getGenealogyId(); - $('[data-genealogy-context-link]').each(function () { + $('[data-genealogy-context-link], a[href]').filter(function () { + var $link = $(this); + + return $link.is('[data-genealogy-context-link]') || isGenealogyContextHref($link.attr('href')); + }).each(function () { var $link = $(this); $link.attr('href', genealogyId ? withGenealogyId($link.attr('href'), genealogyId) : getGenealogyEntryUrl($link.attr('href'))); @@ -463,7 +506,7 @@ syncGenealogyContextLinks(); }); - window.ProfileUI = { + Object.assign(profileUI, { confirm: openConfirm, closeLogout: closeLegacyLogout, initLayui: initLayui, @@ -471,10 +514,13 @@ normalizeGenealogyId: normalizeGenealogyId, withGenealogyId: withGenealogyId, getGenealogyEntryUrl: getGenealogyEntryUrl, + isGenealogyContextHref: isGenealogyContextHref, getCurrentGenealogyContext: getCurrentGenealogyContext, setCurrentGenealogyContext: setCurrentGenealogyContext, clearCurrentGenealogyContext: clearCurrentGenealogyContext, refreshCurrentGenealogyContext: refreshCurrentGenealogyContext, - syncGenealogyContextLinks: syncGenealogyContextLinks - }; + syncGenealogyContextLinks: syncGenealogyContextLinks, + renderApiState: renderApiState, + setApiState: setApiState + }); })(window, window.jQuery || (window.layui && window.layui.$)); diff --git a/public/js/profile-pages.js b/public/js/profile-pages.js index 2b4e983..3d6234f 100644 --- a/public/js/profile-pages.js +++ b/public/js/profile-pages.js @@ -33,25 +33,24 @@ return String(displayName).charAt(0) || '家'; } - function getFinalRegionCode(profile) { - var source = profile || {}; + function getSexText(value) { + var labels = { '0': '男', '1': '女', '2': '未知' }; - return source.districtCode || source.cityCode || source.provinceCode || ''; - } - - function buildRegionText(profile) { - return getFinalRegionCode(profile) ? '加载中...' : '待填写'; + return labels[String(value)] || '待填写'; } function buildProfileView(profile) { // 页面展示模型和接口返回分开,后续改文案不影响接口契约。 return { displayName: profileValue(profile, 'nickName', '未设置昵称'), + realName: profileValue(profile, 'realName', '待填写'), avatarText: getAvatarText(profile), + avatarStatus: profile && profile.avatar ? '已设置' : '未设置', phone: profileValue(profile, 'phone', '未绑定'), - sex: profileValue(profile, 'sex', '待填写'), + sex: getSexText(profile && profile.sex), birthday: profileValue(profile, 'birthday', '待填写'), - regionText: buildRegionText(profile || {}) + email: profileValue(profile, 'email', '待填写'), + regionText: '当前资料接口未提供' }; } @@ -82,6 +81,12 @@ return body; } + function validateProfileUpdateBody(body) { + if (!body || Object.keys(body).length === 0) return '请至少填写一项资料'; + if (body.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) return '请输入正确的邮箱地址'; + return ''; + } + function getApi() { return root.GenealogyApi && root.GenealogyApi.defaultClient; } @@ -129,17 +134,22 @@ } } - function setStatus(form, message) { + function setStatus(form, type, message) { var statusNode = query('[data-profile-status]', form); - if (statusNode) { - statusNode.textContent = message || ''; + if (!statusNode) return; + if (root.ProfileUI && root.ProfileUI.setApiState && type) { + root.ProfileUI.setApiState(statusNode, type, message); + return; } + statusNode.textContent = message || ''; } function setSubmitting(form, isSubmitting) { queryAll('button[type="submit"]', form).forEach(function (button) { + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; button.disabled = isSubmitting; + button.textContent = isSubmitting ? '保存中…' : button.dataset.defaultLabel; }); } @@ -155,18 +165,27 @@ }); } - function setProfileLoadStatus(message) { + function setProfileLoadStatus(type, message) { var statusNode = query('[data-profile-load-status]'); - if (statusNode) statusNode.textContent = message || ''; + if (!statusNode) return; + if (root.ProfileUI && root.ProfileUI.setApiState && type) { + root.ProfileUI.setApiState(statusNode, type, message); + return; + } + statusNode.textContent = message || ''; } function buildUnavailableProfileView() { return { displayName: '账户资料暂时无法读取', + realName: '暂无数据', avatarText: '!', + avatarStatus: '暂无数据', phone: '暂无数据', birthday: '暂无数据', + email: '暂无数据', + sex: '暂无数据', regionText: '暂无数据' }; } @@ -194,53 +213,6 @@ }); } - function buildRegionPathText(items) { - return (Array.isArray(items) ? items : []).map(function (item) { - return item && item.regionName || ''; - }).filter(Boolean).join(' / '); - } - - async function renderRegionText(profile, requestId) { - var api = getApi(); - var regionCode = getFinalRegionCode(profile); - var path; - var text = '待填写'; - - if (!regionCode || !api || !api.regionPath) { - renderProfile({ regionText: text }); - return; - } - - try { - path = await api.regionPath(regionCode); - text = buildRegionPathText(path) || text; - } catch (error) { - if (redirectUnauthorized(api, error)) return; - } - - if (requestId === undefined || requestId === latestProfileRequestId) { - renderProfile({ regionText: text }); - } - } - - async function fillRegionPicker(profile) { - var regionPages = root.RegionPages; - - if (!regionPages || !regionPages.applyProfileRegionSelection) return; - - try { - await regionPages.applyProfileRegionSelection({ - provinceCode: trimOrUndefined(profile && profile.provinceCode), - cityCode: trimOrUndefined(profile && profile.cityCode), - districtCode: trimOrUndefined(profile && profile.districtCode) - }); - } catch (error) { - var api = getApi(); - - if (!redirectUnauthorized(api, error)) showMessage(error.message || '地区资料加载失败'); - } - } - var latestProfileRequestId = 0; async function loadProfile() { @@ -252,40 +224,52 @@ if (!api || !documentRef || !documentRef.querySelector('[data-profile-page]')) return; if (redirectUnauthorized(api)) return; - setProfileLoadStatus('正在读取账户资料…'); + setProfileLoadStatus('loading', '正在读取账户资料…'); try { profile = await api.currentProfile(); if (requestId !== latestProfileRequestId) return; renderProfile(buildProfileView(profile)); fillProfileForm(profile); - fillRegionPicker(profile); - renderRegionText(profile, requestId); - setProfileLoadStatus(''); + setProfileLoadStatus('', ''); } catch (error) { if (redirectUnauthorized(api, error)) return; renderProfile(buildUnavailableProfileView()); - setProfileLoadStatus('资料读取失败,请刷新页面后重试。'); + setProfileLoadStatus('error', '资料读取失败,请刷新页面后重试。'); showMessage(error.message || '个人资料加载失败'); } } async function submitProfileForm(form) { var api = getApi(); + var body; + var validation; if (!api || !form) return; if (redirectUnauthorized(api)) return; + try { + body = buildProfileUpdateBody(getFormValues(form)); + } catch (error) { + setStatus(form, 'error', error.message || '资料校验失败'); + return; + } + validation = validateProfileUpdateBody(body); + if (validation) { + setStatus(form, 'error', validation); + return; + } + try { setSubmitting(form, true); - setStatus(form, '保存中...'); - await api.updateProfile(buildProfileUpdateBody(getFormValues(form))); - setStatus(form, '已保存'); + setStatus(form, 'loading', '正在保存资料…'); + await api.updateProfile(body); + setStatus(form, '', '已保存'); showMessage('个人资料已保存'); await loadProfile(); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus(form, '保存失败'); + setStatus(form, 'error', '保存失败,请稍后重试。'); showMessage(error.message || '个人资料保存失败'); } finally { setSubmitting(form, false); @@ -299,6 +283,11 @@ form.addEventListener('submit', function (event) { event.preventDefault(); + if (!form.checkValidity()) { + setStatus(form, 'error', '请检查资料填写是否正确'); + form.reportValidity(); + return; + } submitProfileForm(form); }); } @@ -310,11 +299,10 @@ return { getAvatarText: getAvatarText, - getFinalRegionCode: getFinalRegionCode, - buildRegionText: buildRegionText, buildProfileView: buildProfileView, buildUnavailableProfileView: buildUnavailableProfileView, buildProfileUpdateBody: buildProfileUpdateBody, + validateProfileUpdateBody: validateProfileUpdateBody, shouldRedirectToLogin: shouldRedirectToLogin, reloadProfile: loadProfile, init: init diff --git a/public/js/region-pages.js b/public/js/region-pages.js index fdd74bd..12f6c01 100644 --- a/public/js/region-pages.js +++ b/public/js/region-pages.js @@ -107,6 +107,10 @@ return true; } + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + function query(selector, rootNode) { if (!documentRef && !rootNode) return null; return (rootNode || documentRef).querySelector(selector); @@ -136,6 +140,21 @@ return values; } + function setApiState(target, type, message) { + if (!target) return; + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message || ''; + } + + function setPickerState(picker, type, message) { + var status = picker && query('[data-region-picker-status]', picker.parentNode); + + setApiState(status, type, message); + } + function setOptions(select, items, placeholder) { var list = normalizeList(items); @@ -186,10 +205,14 @@ } } - function handlePickerError(error) { + function handlePickerError(picker, error) { var api = getApi(); + var message; - if (!redirectUnauthorized(api, error)) showMessage(error.message || '地区加载失败'); + if (redirectUnauthorized(api, error)) return; + message = isForbidden(error) ? '当前账号无权读取地区数据。' : (error.message || '地区加载失败'); + setPickerState(picker, isForbidden(error) ? 'forbidden' : 'error', message); + showMessage(message); } function bindPickerEvents(picker, province, city, district) { @@ -200,17 +223,33 @@ resetSelect(city, '请选择市'); resetSelect(district, '请选择区县'); updateTargetCode(picker); - if (!province.value) return; + if (!province.value) { + setPickerState(picker, '', ''); + return; + } - loadChildrenInto(city, province.value, '请选择市').catch(handlePickerError); + setPickerState(picker, 'loading', '正在加载市…'); + loadChildrenInto(city, province.value, '请选择市').then(function () { + setPickerState(picker, '', ''); + }).catch(function (error) { + handlePickerError(picker, error); + }); }); city.addEventListener('change', function () { resetSelect(district, '请选择区县'); updateTargetCode(picker); - if (!city.value) return; + if (!city.value) { + setPickerState(picker, '', ''); + return; + } - loadChildrenInto(district, city.value, '请选择区县').catch(handlePickerError); + setPickerState(picker, 'loading', '正在加载区县…'); + loadChildrenInto(district, city.value, '请选择区县').then(function () { + setPickerState(picker, '', ''); + }).catch(function (error) { + handlePickerError(picker, error); + }); }); district.addEventListener('change', function () { @@ -229,7 +268,11 @@ resetSelect(city, '请选择市'); resetSelect(district, '请选择区县'); bindPickerEvents(picker, province, city, district); - picker.__regionInitPromise = loadChildrenInto(province, '0', '请选择省').catch(function (error) { + setPickerState(picker, 'loading', '正在加载省…'); + picker.__regionInitPromise = loadChildrenInto(province, '0', '请选择省').then(function (result) { + setPickerState(picker, '', ''); + return result; + }).catch(function (error) { picker.__regionInitPromise = null; throw error; }); @@ -280,12 +323,16 @@ var list = normalizeList(items); if (!container) return; - container.innerHTML = list.length ? list.map(function (item) { + if (!list.length) { + setApiState(container, 'empty', '未找到匹配地区'); + return; + } + container.innerHTML = list.map(function (item) { var code = getRegionCode(item); var name = getRegionName(item); return ''; - }).join('') : '

未找到匹配地区

'; + }).join(''); } async function submitRegionSearch(form) { @@ -293,24 +340,35 @@ var values = getFormValues(form); var results = query('[data-region-search-results]', form.parentNode); var pathText = query('[data-region-search-path]', form.parentNode); + var status = query('[data-region-search-status]', form); + var items; + var message; if (redirectUnauthorized(api)) return; if (!values.regionKeyword || !values.regionKeyword.trim()) { + setApiState(status, 'error', '请输入地区名称或编码'); showMessage('请输入地区名称或编码'); return; } + setApiState(status, 'loading', '正在搜索地区…'); + setApiState(results, 'loading', '正在搜索地区…'); if (pathText) pathText.textContent = '搜索中...'; try { - renderSearchResults(results, await api.regionSearch({ + items = await api.regionSearch({ keyword: values.regionKeyword.trim(), limit: 20 - })); - if (pathText) pathText.textContent = '请选择一个地区'; + }); + renderSearchResults(results, items); + setApiState(status, '', ''); + if (pathText) pathText.textContent = normalizeList(items).length ? '请选择一个地区' : '未找到匹配地区'; } catch (error) { if (redirectUnauthorized(api, error)) return; - if (pathText) pathText.textContent = error.message || '地区搜索失败'; - showMessage(error.message || '地区搜索失败'); + message = isForbidden(error) ? '当前账号无权搜索地区。' : (error.message || '地区搜索失败'); + setApiState(results, isForbidden(error) ? 'forbidden' : 'error', message); + setApiState(status, isForbidden(error) ? 'forbidden' : 'error', message); + if (pathText) pathText.textContent = message; + showMessage(message); } } @@ -320,21 +378,27 @@ var section = button.closest('.module-panel'); var picker = section && query('[data-region-picker]', section); var pathText = section && query('[data-region-search-path]', section); + var status = section && query('[data-region-search-status]', section); var detail; var path; + var message; if (!code || !picker || redirectUnauthorized(api)) return; + setApiState(status, 'loading', '正在定位地区…'); if (pathText) pathText.textContent = '正在定位地区...'; try { detail = await api.regionDetail(code); path = await api.regionPath(getRegionCode(detail) || code); await applyRegionSelection(picker, buildRegionSelection(path)); if (pathText) pathText.textContent = buildPathText(path); + setApiState(status, '', ''); } catch (error) { if (redirectUnauthorized(api, error)) return; - if (pathText) pathText.textContent = error.message || '地区定位失败'; - showMessage(error.message || '地区定位失败'); + message = isForbidden(error) ? '当前账号无权定位地区。' : (error.message || '地区定位失败'); + setApiState(status, isForbidden(error) ? 'forbidden' : 'error', message); + if (pathText) pathText.textContent = message; + showMessage(message); } } @@ -364,7 +428,9 @@ if (redirectUnauthorized(api)) return; queryAll('[data-region-picker]').forEach(function (picker) { - initPicker(picker).catch(handlePickerError); + initPicker(picker).catch(function (error) { + handlePickerError(picker, error); + }); }); bindSearchForms(); } @@ -381,6 +447,7 @@ applyRegionSelection: applyRegionSelection, applyProfileRegionSelection: applyProfileRegionSelection, shouldRedirectToLogin: shouldRedirectToLogin, + isForbidden: isForbidden, init: init }; }); diff --git a/public/js/relative-pages.js b/public/js/relative-pages.js index 9b09b26..af05daf 100644 --- a/public/js/relative-pages.js +++ b/public/js/relative-pages.js @@ -15,6 +15,7 @@ var documentRef = root.document; var recordsById = Object.create(null); + var canEditContent = false; var writePending = false; function getApi() { @@ -280,19 +281,26 @@ return relativeId ? url + '&relativeId=' + encodeURIComponent(relativeId) : url; } + function canEditRecords(genealogy) { + return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true)); + } + function renderRelativeRow(record) { var summary = []; + var actions = ''; if (record.relationName) summary.push(record.relationName); if (record.eventName) summary.push(record.eventName); if (record.eventTime) summary.push(record.eventTime); if (!summary.length) summary.push('未填写关系和事件'); + if (canEditContent) { + actions += '编辑'; + } return '

' + escapeHtml(record.relativeName) + '

' + - escapeHtml(summary.join(' · ')) + '

' + - '' + - '编辑' + - '' + - '
'; + escapeHtml(summary.join(' · ')) + '

' + actions + '
'; } function detailValue(label, value) { @@ -301,9 +309,16 @@ function renderRelativeDetail(record) { var count; + var actions = ''; - if (!record) return '
请选择一条亲友记录查看详情
'; + if (!record) return stateHtml('empty', '请选择一条亲友记录查看详情'); count = attachmentCount(record.mediaOssIds); + if (canEditContent) { + actions = '
编辑记录' + + '
'; + } return '
编辑记录' + - '
'; + '' + actions; } function showMessage(message) { @@ -335,6 +348,10 @@ return Number(error && (error.status || error.code)) === 403; } + function getApiStateType(error) { + return isForbidden(error) ? 'forbidden' : 'error'; + } + function redirectUnauthorized(api, error) { if (!shouldRedirectToLogin(api, error)) return false; if (api && api.clearToken) api.clearToken(); @@ -344,13 +361,38 @@ return true; } - function setFormStatus(message) { - var target = query('[data-relative-form-status]'); - - if (target) target.textContent = message || ''; + function stateHtml(type, message) { + if (root.ProfileUI && root.ProfileUI.renderApiState) return root.ProfileUI.renderApiState(type, message); + return '
' + escapeHtml(message) + '
'; } - function setEditorEnabled(enabled, message) { + function setState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.innerHTML = stateHtml(type, message); + } + + function setFormStatus(message, type) { + var target = query('[data-relative-form-status]'); + + if (!target) return; + if (!message) { + target.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(target, type, message); + return; + } + target.textContent = message; + } + + function setEditorEnabled(enabled, message, type) { var placeholder = query('[data-relative-editor-placeholder]'); queryAll('[data-relative-editor], [data-relative-editor-action]').forEach(function (element) { @@ -358,15 +400,39 @@ }); if (placeholder) { placeholder.hidden = Boolean(enabled); - if (message) placeholder.textContent = message; + if (!enabled && message) { + if (type) setState(placeholder, type, message); + else placeholder.textContent = message; + } } } - function setWritePending(value) { + function setEditAccess(enabled) { + canEditContent = Boolean(enabled); + queryAll('[data-relative-create-link]').forEach(function (element) { + element.hidden = !canEditContent; + }); + } + + async function loadCapability(api, genealogyId) { + var genealogy = await api.genealogyDetail(genealogyId); + + setEditAccess(canEditRecords(genealogy)); + return genealogy; + } + + function setWritePending(value, label) { + var submit = query('[data-relative-form] [type="submit"]'); + writePending = Boolean(value); queryAll('[data-relative-form] button, [data-relative-form] input, [data-relative-form] textarea, [data-relative-delete-id]').forEach(function (control) { control.disabled = writePending; }); + if (submit) { + if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent; + submit.textContent = writePending && label ? label : submit.dataset.defaultLabel; + submit.setAttribute('aria-busy', writePending ? 'true' : 'false'); + } } function syncLinks() { @@ -378,10 +444,11 @@ var detail = query('[data-relative-detail]'); var message = '请先选择家谱,再查看或维护亲友记录。'; - if (list) list.innerHTML = '
' + message + '
'; - if (detail) detail.innerHTML = '
当前没有家谱上下文。
'; - setEditorEnabled(false, message); - setFormStatus(message); + setState(list, 'empty', message); + setState(detail, 'empty', '当前没有家谱上下文。'); + setEditAccess(false); + setEditorEnabled(false, message, 'empty'); + setFormStatus(message, 'empty'); } function requireGenealogyId() { @@ -403,11 +470,11 @@ }); if (!container) return records; if (!Array.isArray(data) || invalidCount) { - container.innerHTML = '
亲友记录响应缺少稳定 RelativeRecordVo 字段,请联系后端核对。
'; + setState(container, 'error', '亲友记录响应缺少稳定 RelativeRecordVo 字段,请联系后端核对。'); return []; } if (!records.length) { - container.innerHTML = '
暂无亲友往来记录
'; + setState(container, 'empty', '暂无亲友往来记录'); return []; } container.innerHTML = records.map(renderRelativeRow).join(''); @@ -426,6 +493,7 @@ var record; if (!genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return null; + setState(query('[data-relative-detail]'), 'loading', '正在加载亲友记录详情…'); try { record = normalizeRelativeRecord(await api.relativeRecordDetail(genealogyId, relativeId)); if (!record) throw new Error('亲友记录详情响应缺少稳定 RelativeRecordVo 字段'); @@ -434,7 +502,11 @@ return record; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); + setState( + query('[data-relative-detail]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该亲友记录。' : '亲友记录详情加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该亲友记录。' : (error.message || '亲友记录详情加载失败')); return null; } @@ -450,7 +522,9 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncLinks(); + setState(query('[data-relative-list]'), 'loading', '正在加载亲友记录…'); try { + await loadCapability(api, genealogyId); records = renderRelativeRecords(await api.relativeRecords(genealogyId)); requestedRelativeId = getCurrentRelativeId(); if (requestedRelativeId && recordsById[requestedRelativeId]) { @@ -462,11 +536,12 @@ } } catch (error) { if (redirectUnauthorized(api, error)) return; - if (query('[data-relative-list]')) { - query('[data-relative-list]').innerHTML = '
' + - (isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : '亲友记录加载失败,请稍后重试。') + - '
'; - } + setEditAccess(false); + setState( + query('[data-relative-list]'), + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : '亲友记录加载失败,请稍后重试。' + ); showMessage(isForbidden(error) ? '当前账号无权查看该家谱的亲友记录。' : (error.message || '亲友记录加载失败')); } } @@ -515,8 +590,11 @@ if (!genealogyId) return; relativeId = getCurrentRelativeId(); syncLinks(); - setEditorEnabled(false, '正在加载亲友记录编辑信息…'); + setEditorEnabled(false, '正在加载亲友记录编辑信息…', 'loading'); + setFormStatus('正在读取家谱权限…', 'loading'); try { + await loadCapability(api, genealogyId); + if (!canEditContent) throw { status: 403, message: '当前账号无权维护该家谱的亲友记录。' }; if (relativeId) { record = normalizeRelativeRecord(await api.relativeRecordDetail(genealogyId, relativeId)); if (!record) throw new Error('亲友记录详情响应缺少稳定 RelativeRecordVo 字段'); @@ -524,12 +602,17 @@ if (query('[data-relative-editor-title]')) query('[data-relative-editor-title]').textContent = '编辑亲友记录'; } setEditorEnabled(true); + setFormStatus(''); } catch (error) { if (redirectUnauthorized(api, error)) return; + setEditAccess(false); setEditorEnabled(false, isForbidden(error) ? '当前账号无权维护该家谱的亲友记录。' - : (error.message || '亲友记录编辑信息加载失败')); - setFormStatus(error.message || '亲友记录编辑信息加载失败'); + : (error.message || '亲友记录编辑信息加载失败'), getApiStateType(error)); + setFormStatus( + isForbidden(error) ? '当前账号无权维护该家谱的亲友记录。' : (error.message || '亲友记录编辑信息加载失败'), + getApiStateType(error) + ); } } @@ -544,18 +627,18 @@ var savedId; var verifiedDetail; - if (writePending || !genealogyId || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; body = buildRelativeRecordBody(getFormValues(form)); validation = validateRelativeRecordBody(body); if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } delete body.invalidGiftAmount; delete body.invalidGiftPrecision; delete body.invalidSortOrder; - setWritePending(true); - setFormStatus('正在保存亲友记录…'); + setWritePending(true, '正在保存…'); + setFormStatus('正在保存亲友记录…', 'loading'); try { result = relativeId ? await api.updateRelativeRecord(genealogyId, relativeId, body) @@ -568,7 +651,10 @@ if (root.location) root.location.href = buildListUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setFormStatus(isForbidden(error) ? '当前账号无权保存该亲友记录。' : (error.message || '亲友记录保存失败')); + setFormStatus( + isForbidden(error) ? '当前账号无权保存该亲友记录。' : (error.message || '亲友记录保存失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -579,7 +665,7 @@ var genealogyId = requireGenealogyId(); var record = recordsById[relativeId]; - if (writePending || !genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return; + if (writePending || !canEditContent || !genealogyId || !normalizeId(relativeId) || redirectUnauthorized(api)) return; if (root.confirm && !root.confirm('确认删除“' + (record ? record.relativeName : '该亲友记录') + '”吗?删除后不可恢复,并会释放附件引用。')) return; setWritePending(true); try { @@ -649,6 +735,7 @@ validateRelativeRecordBody: validateRelativeRecordBody, normalizeRelativeRecord: normalizeRelativeRecord, matchesSavedRecord: matchesSavedRecord, + canEditRecords: canEditRecords, isEditableRecord: isEditableRecord, renderRelativeDetail: renderRelativeDetail, shouldRedirectToLogin: shouldRedirectToLogin, diff --git a/public/js/security-pages.js b/public/js/security-pages.js index a084a01..7c0d96e 100644 --- a/public/js/security-pages.js +++ b/public/js/security-pages.js @@ -143,15 +143,32 @@ return values; } - function setStatus(form, message) { + function setStatus(form, type, message) { var status = query('[data-security-status]', form); - if (status) status.textContent = message; + if (!status) return; + if (root.ProfileUI && root.ProfileUI.setApiState && type) { + root.ProfileUI.setApiState(status, type, message); + return; + } + status.textContent = message || ''; } function setSubmitting(form, isSubmitting) { queryAll('button[type="submit"]', form).forEach(function (button) { + if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; button.disabled = isSubmitting; + button.textContent = isSubmitting ? '提交中…' : button.dataset.defaultLabel; + }); + } + + function setBoundPhoneStatus(type, message) { + queryAll('[data-security-bound-phone]').forEach(function (node) { + if (root.ProfileUI && root.ProfileUI.setApiState && type) { + root.ProfileUI.setApiState(node, type, message); + return; + } + node.textContent = message || ''; }); } @@ -211,6 +228,7 @@ if (!api || !documentRef) return; if (redirectUnauthorized(api)) return; + setBoundPhoneStatus('loading', '正在读取已绑定手机号…'); try { profile = await api.currentProfile(); @@ -218,14 +236,10 @@ message = isPhone(boundPhone) ? '验证码将发送至 ' + maskPhone(boundPhone) : '未获取到已绑定手机号'; - queryAll('[data-security-bound-phone]').forEach(function (node) { - node.textContent = message; - }); + setBoundPhoneStatus('', message); } catch (error) { if (redirectUnauthorized(api, error)) return; - queryAll('[data-security-bound-phone]').forEach(function (node) { - node.textContent = '读取已绑定手机号失败'; - }); + setBoundPhoneStatus('error', '读取已绑定手机号失败,请刷新后重试。'); showMessage(error.message || '账号资料加载失败'); } } @@ -236,28 +250,31 @@ if (redirectUnauthorized(api)) return; if (!values.oldPassword || !values.newPassword) { + setStatus(form, 'error', '请填写当前密码和新密码'); showMessage('请填写原密码和新密码'); return; } if (String(values.newPassword).length < 6) { + setStatus(form, 'error', '新密码至少需要 6 位'); showMessage('新密码至少需要 6 位'); return; } if (values.newPassword !== values.confirmPassword) { + setStatus(form, 'error', '两次新密码不一致'); showMessage('两次新密码不一致'); return; } try { setSubmitting(form, true); - setStatus(form, '提交中...'); + setStatus(form, 'loading', '正在修改密码…'); await api.changePassword(buildPasswordChangeBody(values)); - setStatus(form, '密码已修改'); + setStatus(form, '', '密码已修改'); showMessage('密码已修改'); form.reset(); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus(form, '修改失败'); + setStatus(form, 'error', '修改失败,请稍后重试。'); showMessage(error.message || '密码修改失败'); } finally { setSubmitting(form, false); @@ -270,32 +287,33 @@ if (redirectUnauthorized(api)) return; if (!body.phone || !body.smsCode) { + setStatus(form, 'error', '请填写新手机号和验证码'); showMessage('请填写新手机号和验证码'); return; } if (!isPhone(body.phone)) { + setStatus(form, 'error', '请输入正确的新手机号'); showMessage('请输入正确的新手机号'); return; } if (!isSmsCode(body.smsCode)) { + setStatus(form, 'error', '请输入正确的短信验证码'); showMessage('请输入正确的短信验证码'); return; } try { setSubmitting(form, true); - setStatus(form, '提交中...'); + setStatus(form, 'loading', '正在换绑手机号…'); await api.changePhone(body); boundPhone = body.phone; - queryAll('[data-security-bound-phone]').forEach(function (node) { - node.textContent = '验证码将发送至 ' + maskPhone(boundPhone); - }); - setStatus(form, '手机号已换绑'); + setBoundPhoneStatus('', '验证码将发送至 ' + maskPhone(boundPhone)); + setStatus(form, '', '手机号已换绑'); showMessage('手机号已换绑'); form.reset(); } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus(form, '换绑失败'); + setStatus(form, 'error', '换绑失败,请稍后重试。'); showMessage(error.message || '手机号换绑失败'); } finally { setSubmitting(form, false); @@ -311,6 +329,7 @@ if (!form || redirectUnauthorized(api)) return; values = getFormValues(form); if (!isPhone(values.phone)) { + setStatus(form, 'error', '请输入正确的新手机号'); showMessage('请输入正确的新手机号'); return; } @@ -318,16 +337,19 @@ values = getFormValues(form); values.validToken = takeCaptchaToken(form); button.disabled = true; + setStatus(form, 'loading', '正在发送验证码…'); try { await api.sendSmsCode(getCaptchaOperation('phone'), { phone: values.phone, validToken: values.validToken || '' }); + setStatus(form, '', '验证码已发送,请查收短信。'); showMessage('验证码已发送'); startSmsCooldown(button); } catch (error) { if (redirectUnauthorized(api, error)) return; + setStatus(form, 'error', '验证码发送失败,请稍后重试。'); showMessage(error.message || '验证码发送失败'); button.disabled = false; } @@ -340,6 +362,7 @@ if (!form || redirectUnauthorized(api)) return; if (!isPhone(boundPhone)) { + setStatus(form, 'error', '未获取到已绑定手机号,请刷新后重试。'); showMessage('未获取到已绑定手机号,请刷新后重试'); return; } @@ -347,16 +370,19 @@ values = getFormValues(form); values.validToken = takeCaptchaToken(form); button.disabled = true; + setStatus(form, 'loading', '正在发送验证码…'); try { await api.sendSmsCode(getCaptchaOperation('deactivate'), { phone: boundPhone, validToken: values.validToken || '' }); + setStatus(form, '', '验证码已发送,请查收短信。'); showMessage('验证码已发送'); startSmsCooldown(button); } catch (error) { if (redirectUnauthorized(api, error)) return; + setStatus(form, 'error', '验证码发送失败,请稍后重试。'); showMessage(error.message || '验证码发送失败'); button.disabled = false; } @@ -368,10 +394,12 @@ if (redirectUnauthorized(api)) return; if (!isSmsCode(values.smsCode)) { + setStatus(form, 'error', '请输入正确的短信验证码'); showMessage('请输入正确的短信验证码'); return; } if (!isDangerConfirmed(values.confirmText)) { + setStatus(form, 'error', '请完整输入“确认注销”'); showMessage('请完整输入“确认注销”'); return; } @@ -379,13 +407,13 @@ try { setSubmitting(form, true); - setStatus(form, '提交中...'); + setStatus(form, 'loading', '正在提交注销申请…'); await api.deactivateAccount(buildDeactivateBody(values)); showMessage('账号已提交注销'); root.location.href = 'index.html'; } catch (error) { if (redirectUnauthorized(api, error)) return; - setStatus(form, '注销失败'); + setStatus(form, 'error', '注销失败,请稍后重试。'); showMessage(error.message || '账号注销失败'); } finally { setSubmitting(form, false); @@ -415,6 +443,11 @@ if (!form) return; event.preventDefault(); + if (!form.checkValidity()) { + setStatus(form, 'error', '请检查表单填写是否正确'); + form.reportValidity(); + return; + } formType = form.getAttribute('data-security-form'); if (formType === 'password') submitPassword(form); diff --git a/public/js/site-news-pages.js b/public/js/site-news-pages.js index f62421c..56887f1 100644 --- a/public/js/site-news-pages.js +++ b/public/js/site-news-pages.js @@ -91,7 +91,6 @@ } function renderNormalizedArticleList(items) { - if (!items.length) return '
当前暂无资讯
'; return items.map(function (item) { return '' + @@ -131,9 +130,7 @@ function renderArticleDetail(item) { var article = normalizeArticle(item); - return article - ? renderNormalizedArticleDetail(article) - : '
资讯不存在或已下线
'; + return article ? renderNormalizedArticleDetail(article) : ''; } function readQueryValue(search, key) { @@ -178,6 +175,27 @@ return match; } + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + + function isMissingArticleError(error) { + return error && error.message === '资讯不存在或已下线'; + } + + function setSiteState(container, type, message) { + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.textContent = message; + } + + function setSiteStatus(container, message) { + if (container) container.textContent = message || ''; + } + async function init() { var document = root.document; var api = root.GenealogyApi && root.GenealogyApi.defaultClient; @@ -196,19 +214,24 @@ requestedType = readQueryValue(search, 'articleType'); if (!list) return; if (requestedType && !normalizeArticleType(requestedType)) { - list.innerHTML = '
资讯分类无效
'; - if (status) status.textContent = '资讯分类无效'; + setSiteState(list, 'error', '资讯分类无效。'); + setSiteStatus(status, '资讯分类无效。'); return; } + setSiteState(list, 'loading', '正在读取资讯…'); try { - list.classList.add('is-loading'); - list.innerHTML = renderNormalizedArticleList(await loadArticles(api, requestedType)); - if (status) status.textContent = ''; + var items = await loadArticles(api, requestedType); + + if (!items.length) setSiteState(list, 'empty', '当前暂无资讯。'); + else list.innerHTML = renderNormalizedArticleList(items); + setSiteStatus(status, ''); } catch (error) { - list.innerHTML = '
资讯读取失败,请稍后重试
'; - if (status) status.textContent = error.message || '资讯读取失败'; - } finally { - list.classList.remove('is-loading'); + setSiteState( + list, + isForbidden(error) ? 'forbidden' : 'error', + isForbidden(error) ? '当前账号无权查看资讯。' : '资讯读取失败,请稍后重试。' + ); + setSiteStatus(status, error.message || '资讯读取失败,请稍后重试。'); } return; } @@ -219,20 +242,23 @@ articleId = readArticleId(search); if (!detail) return; if (!articleId) { - detail.innerHTML = '
资讯编号无效
'; - if (status) status.textContent = '资讯编号无效'; + setSiteState(detail, 'error', '资讯编号无效。'); + setSiteStatus(status, '资讯编号无效。'); return; } + setSiteState(detail, 'loading', '正在读取资讯详情…'); try { - detail.classList.add('is-loading'); detail.innerHTML = renderNormalizedArticleDetail(await loadArticleDetail(api, articleId)); - if (status) status.textContent = ''; + setSiteStatus(status, ''); } catch (error) { - detail.innerHTML = '
' + - escapeHtml(error.message || '资讯读取失败,请稍后重试') + '
'; - if (status) status.textContent = error.message || '资讯读取失败'; - } finally { - detail.classList.remove('is-loading'); + setSiteState( + detail, + isForbidden(error) ? 'forbidden' : (isMissingArticleError(error) ? 'empty' : 'error'), + isForbidden(error) + ? '当前账号无权查看该资讯。' + : (isMissingArticleError(error) ? '资讯不存在或已下线。' : '资讯详情读取失败,请稍后重试。') + ); + setSiteStatus(status, error.message || '资讯详情读取失败,请稍后重试。'); } } } @@ -247,6 +273,9 @@ renderArticleDetail: renderArticleDetail, loadArticles: loadArticles, loadArticleDetail: loadArticleDetail, + isForbidden: isForbidden, + isMissingArticleError: isMissingArticleError, + setSiteState: setSiteState, init: init }; }); diff --git a/public/js/upload-pages.js b/public/js/upload-pages.js index 06f1c34..129b7b0 100644 --- a/public/js/upload-pages.js +++ b/public/js/upload-pages.js @@ -60,10 +60,29 @@ return true; } + function isForbidden(error) { + return Number(error && (error.status || error.code)) === 403; + } + function query(selector, rootNode) { return (rootNode || documentRef).querySelector(selector); } + function queryAll(selector, rootNode) { + return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)); + } + + function setUploadStatus(status, type, message) { + if (!status) return; + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(status, type, message); + return; + } + status.textContent = message || ''; + } + function showMessage(message) { if (root.layui && root.layui.layer) { root.layui.layer.msg(message); @@ -214,22 +233,20 @@ try { for (index = 0; index < files.length; index += 1) { - if (status) status.textContent = files[index].name + ' 正在上传...'; + setUploadStatus(status, 'loading', files[index].name + ' 正在上传…'); result = normalizeUploadResult(await uploadFileForPage(api, files[index], { onProgress: function (progress) { - if (status) { - status.textContent = '正在上传 ' + progress.uploadedChunks + '/' + progress.totalChunks + ' 分片'; - } + setUploadStatus(status, 'loading', '正在上传 ' + progress.uploadedChunks + '/' + progress.totalChunks + ' 分片'); } })); if (!result.ossId) throw new Error('上传响应缺少 ossId'); target.value = mergeUploadTargetValue(target.value, result.ossId, multiple); - if (status) status.textContent = buildUploadStatus(result.fileName || files[index].name, result.ossId); + setUploadStatus(status, '', buildUploadStatus(result.fileName || files[index].name, result.ossId)); } } catch (error) { if (redirectUnauthorized(api, error)) return; - if (status) status.textContent = error.message || '上传失败'; - showMessage(error.message || '上传失败'); + setUploadStatus(status, isForbidden(error) ? 'forbidden' : 'error', isForbidden(error) ? '当前账号无权上传文件。' : (error.message || '上传失败')); + showMessage(isForbidden(error) ? '当前账号无权上传文件。' : (error.message || '上传失败')); } finally { input.disabled = false; input.value = ''; @@ -248,6 +265,10 @@ } function init() { + queryAll('[data-upload-status]').forEach(function (status) { + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + }); bindActions(); } @@ -259,6 +280,7 @@ uploadFileForPage: uploadFileForPage, mergeUploadTargetValue: mergeUploadTargetValue, shouldRedirectToLogin: shouldRedirectToLogin, + isForbidden: isForbidden, init: init }; }); diff --git a/public/js/video-pages.js b/public/js/video-pages.js index ec7f299..6bdb781 100644 --- a/public/js/video-pages.js +++ b/public/js/video-pages.js @@ -232,7 +232,7 @@ function renderVideoDetail(video, managementAccess) { var actions = ''; - if (!video) return '
请选择一个视频查看详情
'; + if (!video) return ''; if (managementAccess) { actions = '
编辑视频' + @@ -287,15 +287,15 @@ if (root.ProfileUI && root.ProfileUI.syncGenealogyContextLinks) root.ProfileUI.syncGenealogyContextLinks(); } - function renderNoContext() { - var list = query('[data-video-list]'); - var detail = query('[data-video-detail]'); - var placeholder = query('[data-video-permission-placeholder]'); + function canEditVideos(genealogy) { + return Boolean(genealogy && (genealogy.canEditContent === true || genealogy.canManage === true)); + } - if (list) list.innerHTML = '
请先选择家谱,再查看家族视频。
'; - if (detail) detail.innerHTML = '
当前没有家谱上下文。
'; - if (placeholder) placeholder.textContent = '请先选择家谱,再发布视频。'; - setFormStatus('请先选择家谱,再发布视频。'); + function renderNoContext() { + setVideoListState('empty', '请先选择家谱,再查看家族视频。'); + setVideoDetailState('empty', '当前没有家谱上下文。'); + setPermissionPlaceholder('empty', '请先选择家谱,再发布视频。'); + setFormStatus('请先选择家谱,再发布视频。', 'forbidden'); setManagementAccess(false); } @@ -316,17 +316,67 @@ if (placeholder) placeholder.hidden = canEditContent; } - function setFormStatus(message) { - var status = query('[data-video-form-status]'); - - if (status) status.textContent = message || ''; + function getApiStateType(error) { + return isForbidden(error) ? 'forbidden' : 'error'; } - function setWritePending(pending) { + function setVideoState(selector, type, message) { + var container = query(selector); + + if (!container) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(container, type, message); + return; + } + container.textContent = message; + } + + function setVideoListState(type, message) { + setVideoState('[data-video-list]', type, message); + } + + function setVideoDetailState(type, message) { + setVideoState('[data-video-detail]', type, message); + } + + function setPermissionPlaceholder(type, message) { + var placeholder = query('[data-video-permission-placeholder]'); + + if (!placeholder) return; + if (root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(placeholder, type, message); + return; + } + placeholder.textContent = message; + } + + function setFormStatus(message, type) { + var status = query('[data-video-form-status]'); + + if (!status) return; + if (!message) { + status.innerHTML = ''; + return; + } + if (type && root.ProfileUI && root.ProfileUI.setApiState) { + root.ProfileUI.setApiState(status, type, message); + return; + } + status.textContent = message; + } + + function setWritePending(pending, label) { + var submit = query('[data-video-form] [type="submit"]'); + writePending = Boolean(pending); queryAll('[data-video-form] input, [data-video-form] textarea, [data-video-form] button, [data-video-delete-id]').forEach(function (control) { control.disabled = writePending; }); + if (submit) { + if (!submit.dataset.defaultLabel) submit.dataset.defaultLabel = submit.textContent; + submit.textContent = writePending && label ? label : submit.dataset.defaultLabel; + submit.setAttribute('aria-busy', writePending ? 'true' : 'false'); + } } function renderVideos(data) { @@ -341,11 +391,11 @@ }); if (!container) return videos; if (!Array.isArray(data) || invalidCount) { - container.innerHTML = '
视频响应缺少稳定 VideoVo 字段,请联系后端核对。
'; + setVideoListState('error', '视频响应缺少稳定 VideoVo 字段,请联系后端核对。'); return []; } if (!videos.length) { - container.innerHTML = '
暂无家族视频
'; + setVideoListState('empty', '暂无家族视频'); return []; } container.innerHTML = videos.map(function (video) { @@ -357,7 +407,12 @@ function renderDetail(video) { var container = query('[data-video-detail]'); - if (container) container.innerHTML = renderVideoDetail(video, canEditContent); + if (!container) return; + if (!video) { + setVideoDetailState('empty', '请选择一个视频查看详情'); + return; + } + container.innerHTML = renderVideoDetail(video, canEditContent); } async function loadVideoDetail(videoId) { @@ -366,6 +421,7 @@ var video; if (!genealogyId || !normalizeId(videoId) || redirectUnauthorized(api)) return null; + setVideoDetailState('loading', '正在加载视频详情…'); try { video = normalizeVideo(await api.videoDetail(genealogyId, videoId)); if (!video) throw new Error('视频详情响应缺少稳定 VideoVo 字段'); @@ -374,7 +430,10 @@ return video; } catch (error) { if (redirectUnauthorized(api, error)) return null; - renderDetail(null); + setVideoDetailState( + getApiStateType(error), + isForbidden(error) ? '当前账号无权查看该视频。' : (error.message || '视频详情加载失败') + ); showMessage(error.message || '视频详情加载失败'); return null; } @@ -391,9 +450,11 @@ genealogyId = requireGenealogyId(); if (!genealogyId) return; syncGenealogyLinks(); + setVideoListState('loading', '正在加载视频…'); + setVideoDetailState('loading', '正在加载视频详情…'); try { result = await Promise.all([api.genealogyDetail(genealogyId), api.videos(genealogyId)]); - setManagementAccess(Boolean(result[0] && result[0].canEditContent === true)); + setManagementAccess(canEditVideos(result[0])); videos = renderVideos(result[1]); requestedVideoId = getCurrentVideoId(); if (requestedVideoId && videosById[requestedVideoId]) { @@ -407,10 +468,12 @@ if (redirectUnauthorized(api, error)) return; if (isForbidden(error)) { setManagementAccess(false); - if (query('[data-video-list]')) query('[data-video-list]').innerHTML = '
当前账号无权查看该家谱视频。
'; + setVideoListState('forbidden', '当前账号无权查看该家谱视频。'); + setVideoDetailState('forbidden', '当前账号无权查看该家谱视频。'); return; } - if (query('[data-video-list]')) query('[data-video-list]').innerHTML = '
视频加载失败,请稍后重试。
'; + setVideoListState('error', '视频加载失败,请稍后重试。'); + setVideoDetailState('error', '视频详情加载失败,请稍后重试。'); showMessage(error.message || '视频加载失败'); } } @@ -454,17 +517,17 @@ if (!genealogyId) return; videoId = getCurrentVideoId(); syncGenealogyLinks(); + setFormStatus('正在读取家谱权限…', 'loading'); + setPermissionPlaceholder('loading', '正在核对视频维护权限…'); try { result = await Promise.all([ api.genealogyDetail(genealogyId), videoId ? api.videoDetail(genealogyId, videoId) : Promise.resolve(null) ]); - setManagementAccess(Boolean(result[0] && result[0].canEditContent === true)); + setManagementAccess(canEditVideos(result[0])); if (!canEditContent) { - if (query('[data-video-permission-placeholder]')) { - query('[data-video-permission-placeholder]').textContent = '当前账号没有视频维护权限。'; - } - setFormStatus('当前账号没有视频维护权限。'); + setPermissionPlaceholder('forbidden', '当前账号没有视频维护权限。'); + setFormStatus('当前账号没有视频维护权限。', 'forbidden'); return; } if (videoId) { @@ -473,15 +536,18 @@ fillVideoForm(video); if (query('[data-video-editor-title]')) query('[data-video-editor-title]').textContent = '编辑视频'; } + setFormStatus(''); } catch (error) { if (redirectUnauthorized(api, error)) return; setManagementAccess(false); - if (query('[data-video-permission-placeholder]')) { - query('[data-video-permission-placeholder]').textContent = isForbidden(error) - ? '当前账号没有视频维护权限。' - : (error.message || '视频编辑信息加载失败'); - } - setFormStatus(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败')); + setPermissionPlaceholder( + getApiStateType(error), + isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败') + ); + setFormStatus( + isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频编辑信息加载失败'), + getApiStateType(error) + ); } } @@ -497,13 +563,13 @@ if (writePending || !canEditContent || !genealogyId || redirectUnauthorized(api)) return; if (validation) { - setFormStatus(validation); + setFormStatus(validation, 'error'); return; } delete body.invalidDuration; delete body.invalidSortOrder; - setWritePending(true); - setFormStatus('正在保存视频…'); + setWritePending(true, '正在保存…'); + setFormStatus('正在保存视频…', 'loading'); try { result = videoId ? await api.updateVideo(genealogyId, videoId, body) : await api.createVideo(genealogyId, body); saved = normalizeVideo(result); @@ -513,7 +579,10 @@ if (root.location) root.location.href = buildListUrl(genealogyId, savedId); } catch (error) { if (redirectUnauthorized(api, error)) return; - setFormStatus(isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频保存失败')); + setFormStatus( + isForbidden(error) ? '当前账号没有视频维护权限。' : (error.message || '视频保存失败'), + getApiStateType(error) + ); } finally { setWritePending(false); } @@ -607,8 +676,10 @@ validateVideoBody: validateVideoBody, normalizeVideo: normalizeVideo, normalizeDurationSeconds: normalizeDurationSeconds, + canEditVideos: canEditVideos, renderVideoRow: renderVideoRow, renderVideoDetail: renderVideoDetail, + setPermissionPlaceholder: setPermissionPlaceholder, shouldRedirectToLogin: shouldRedirectToLogin, isForbidden: isForbidden, init: init diff --git a/public/js/vip-pages.js b/public/js/vip-pages.js index 7671620..3d19720 100644 --- a/public/js/vip-pages.js +++ b/public/js/vip-pages.js @@ -119,6 +119,20 @@ }; } + function normalizeGenealogyOptions(data) { + if (!Array.isArray(data)) return []; + return data.map(normalizeGenealogyOption).filter(Boolean); + } + + function resolveContextGenealogyId(options, contextId) { + var normalizedContextId = normalizeId(contextId); + + if (!normalizedContextId || !Array.isArray(options)) return ''; + return options.some(function (option) { + return option.genealogyId === normalizedContextId; + }) ? normalizedContextId : ''; + } + function buildVipOrderBody(values) { var source = values || {}; var body = {}; @@ -214,7 +228,7 @@ } function renderGenealogyOptions(data) { - var items = Array.isArray(data) ? data.map(normalizeGenealogyOption).filter(Boolean) : []; + var items = normalizeGenealogyOptions(data); return '' + items.map(function (item) { return '
')); + + assert.match(main, /

账户与服务<\/h2>/); + [ + 'profile-messages.html', + 'profile-data.html', + 'profile-security.html', + 'profile-services.html', + 'profile-feedback.html', + 'app.html' + ].forEach((href) => { + assert.match(main, new RegExp('href="' + href.replace('.', '\\.') + '"')); + }); + assert.doesNotMatch(main, /href="profile-(?:article|album|video|merit|gift|join-review)\.html"/); +}); + +test('个人中心首页概览只展示已声明的账户资料字段', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'profile.html'), 'utf8'); + + ['phone', 'displayName', 'realName', 'sex', 'birthday', 'email'].forEach((field) => { + assert.match(source, new RegExp('data-profile-text="' + field + '"')); + }); + assert.doesNotMatch(source, /data-profile-text="regionText"/); + assert.match(source, /
/); }); diff --git a/tests/relative-pages.test.js b/tests/relative-pages.test.js index 39d6cd8..ef762ed 100644 --- a/tests/relative-pages.test.js +++ b/tests/relative-pages.test.js @@ -180,7 +180,7 @@ test('亲友记录编辑器拒绝不可重读的停用记录', () => { assert.equal(RelativePages.isEditableRecord(null), false); }); -test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或状态', () => { +test('亲友记录列表和编辑页按家谱能力开放 PC CRUD,且不允许手填 OSS ID 或状态', () => { const root = path.join(__dirname, '..'); const listPage = fs.readFileSync(path.join(root, 'profile-relative.html'), 'utf8'); const editPage = fs.readFileSync(path.join(root, 'profile-relative-edit.html'), 'utf8'); @@ -188,10 +188,15 @@ test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或 assert.match(listPage, /data-relative-list/); assert.match(listPage, /data-relative-detail/); + assert.match(listPage, /data-relative-create-link hidden/); assert.doesNotMatch(listPage, /原始 JSON|不开放编辑和删除/); assert.match(editPage, /name="eventTime" type="datetime-local"/); + assert.match(editPage, /data-relative-form[^>]*novalidate/); + assert.match(editPage, /for="relativeName">亲友姓名[\s\S]*api-state--loading" role="status" aria-live="polite" aria-busy="true"/); assert.match(editPage, /data-relative-clear-media/); assert.doesNotMatch(editPage, /name="(?:mediaOssIds|status)"[^>]*type="text"/); assert.match(editPage, /public\/js\/md5\.js/); @@ -199,3 +204,28 @@ test('亲友记录列表和编辑页开放 PC CRUD 且不允许手填 OSS ID 或 assert.match(editPage, /public\/js\/relative-pages\.js/); assert.match(styles, /\[data-relative-editor\]\[hidden\][^{]*\{[^}]*display:\s*none\s*!important/s); }); + +test('亲友记录只向内容维护者或管理员开放写操作', () => { + const root = path.join(__dirname, '..'); + const script = fs.readFileSync(path.join(root, 'public', 'js', 'relative-pages.js'), 'utf8'); + + assert.equal(RelativePages.canEditRecords({ canEditContent: true }), true); + assert.equal(RelativePages.canEditRecords({ canManage: true }), true); + assert.equal(RelativePages.canEditRecords({ canEditContent: false, canManage: false }), false); + assert.match(script, /await loadCapability\(api, genealogyId\);/); + assert.match(script, /if \(writePending \|\| !canEditContent \|\| !genealogyId/); + assert.match(script, /if \(canEditContent\) \{[\s\S]*data-relative-delete-id/); +}); + +test('亲友记录使用统一的加载、空数据、失败和无权限状态,以及提交中的按钮反馈', () => { + const root = path.join(__dirname, '..'); + const script = fs.readFileSync(path.join(root, 'public', 'js', 'relative-pages.js'), 'utf8'); + + assert.match(script, /function getApiStateType\(error\)[\s\S]*isForbidden\(error\) \? 'forbidden' : 'error'/); + assert.match(script, /setState\(query\('\[data-relative-list\]'\), 'loading', '正在加载亲友记录…'\)/); + assert.match(script, /setState\(query\('\[data-relative-detail\]'\), 'loading', '正在加载亲友记录详情…'\)/); + assert.match(script, /setFormStatus\('正在读取家谱权限…', 'loading'\)/); + assert.match(script, /setFormStatus\(validation, 'error'\)/); + assert.match(script, /setWritePending\(true, '正在保存…'\)/); + assert.match(script, /setFormStatus\('正在保存亲友记录…', 'loading'\)/); +}); diff --git a/tests/security-upload-scope.test.js b/tests/security-upload-scope.test.js index 8672357..27a0250 100644 --- a/tests/security-upload-scope.test.js +++ b/tests/security-upload-scope.test.js @@ -190,3 +190,30 @@ test('security page field names match phone-change and account-deactivation DTOs assert.match(source, /id="deactivateSmsCode"\s+name="smsCode"/); assert.match(source, /data-security-send-deactivate-code/); }); + +test('上传状态复用统一组件并区分无权限', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'upload-pages.js'), 'utf8'); + + assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/); + assert.match(source, /setUploadStatus\(status, 'loading', files\[index\]\.name \+ ' 正在上传…'\)/); + assert.match(source, /当前账号无权上传文件。/); + assert.match(source, /status\.setAttribute\('role', 'status'\)/); + assert.equal(UploadPages.isForbidden({ status: 403 }), true); + assert.equal(UploadPages.isForbidden({ status: 401 }), false); +}); + +test('security forms provide visible required labels and unified validation, loading and failure feedback', () => { + const page = fs.readFileSync(path.join(__dirname, '..', 'profile-security.html'), 'utf8'); + const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'security-pages.js'), 'utf8'); + + assert.match(page, /data-security-form="password" novalidate/); + assert.match(page, /data-security-form="phone" novalidate/); + assert.match(page, /data-security-form="deactivate" novalidate/); + assert.match(page, /field-required/); + assert.match(page, /data-security-status[^>]*role="status"/); + assert.match(page, /data-security-bound-phone[^>]*role="status"/); + assert.doesNotMatch(page, /微信快捷登录|账号安全提醒/); + assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/); + assert.match(script, /function setBoundPhoneStatus\(type, message\)/); + assert.match(script, /setStatus\(form, 'error', '验证码发送失败,请稍后重试。'\)/); +}); diff --git a/tests/site-news-pages.test.js b/tests/site-news-pages.test.js index da3c2fb..ce27ba4 100644 --- a/tests/site-news-pages.test.js +++ b/tests/site-news-pages.test.js @@ -74,7 +74,7 @@ test('站点资讯列表使用响应 ID 生成详情链接并隐藏内部字段' assert.match(html, /<script>/); assert.match(html, /<img src=x>/); assert.doesNotMatch(html, / { @@ -159,19 +159,36 @@ test('详情 URL 只读取安全字符串文章 ID', () => { assert.equal(SiteNewsPages.readArticleId(''), ''); }); +test('资讯列表和详情将加载、空数据、失败及无权限写入统一状态组件', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../public/js/site-news-pages.js'), 'utf8'); + + assert.equal(SiteNewsPages.isForbidden({ status: 403 }), true); + assert.equal(SiteNewsPages.isForbidden({ status: 401 }), false); + assert.equal(SiteNewsPages.isMissingArticleError(new Error('资讯不存在或已下线')), true); + assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/); + assert.match(source, /setSiteState\(list, 'loading', '正在读取资讯…'\)/); + assert.match(source, /setSiteState\(detail, 'loading', '正在读取资讯详情…'\)/); + assert.match(source, /isForbidden\(error\) \? 'forbidden' : 'error'/); + assert.match(source, /isForbidden\(error\) \? 'forbidden' : \(isMissingArticleError\(error\) \? 'empty' : 'error'\)/); +}); + test('官网资讯列表与详情页加载同一个公开内容 owner 且不暴露业务 ID', () => { assert.match(newsPage, /]*data-site-news-page/); assert.match(newsPage, /data-site-news-list/); - assert.match(newsPage, /data-site-news-status/); + assert.match(newsPage, /data-site-news-status[^>]*role="status"/); + assert.match(newsPage, /data-api-state="loading"/); assert.match(newsPage, /news\.html\?articleType=news/); assert.match(newsPage, /news\.html\?articleType=notice/); assert.match(newsPage, /news\.html\?articleType=download/); assert.match(newsPage, /src="public\/js\/site-news-pages\.js"/); + assert.match(newsPage, /src="public\/js\/profile-common\.js"/); assert.match(detailPage, /]*data-site-article-page/); assert.match(detailPage, /资讯详情 - 代代相传<\/title>/); assert.match(detailPage, /data-site-article-detail/); - assert.match(detailPage, /data-site-article-status/); + assert.match(detailPage, /data-site-article-status[^>]*role="status"/); + assert.match(detailPage, /data-api-state="loading"/); + assert.match(detailPage, /src="public\/js\/profile-common\.js"/); assert.match(detailPage, /src="public\/js\/site-news-pages\.js"/); assert.doesNotMatch(detailPage, /src="public\/js\/article-pages\.js"/); assert.doesNotMatch(detailPage, /href="article-detail\.html"/); diff --git a/tests/stage6-navigation.test.js b/tests/stage6-navigation.test.js index a93e2f3..2ac36fc 100644 --- a/tests/stage6-navigation.test.js +++ b/tests/stage6-navigation.test.js @@ -18,6 +18,13 @@ test('内容发布页为全部家谱功能入口透传真实家谱上下文', () }); }); +test('内容工作台在未选择家谱时使用统一的空上下文状态', () => { + const page = read('profile-content.html'); + + assert.match(page, /api-state--empty[^>]*data-api-state="empty"/); + assert.doesNotMatch(page, /api-empty/); +}); + test('家谱主页公开谱文、相册、视频、功德和祭祀活动入口并透传上下文', () => { const page = read('profile-family-home.html'); @@ -66,6 +73,6 @@ test('个人中心、我的家谱和当前家谱主页使用明确且可返回 assert.match(account, /<a href="profile-families\.html">我的家谱<\/a>/); assert.match(families, /<nav class="profile-path" aria-label="当前位置">[\s\S]*个人中心[\s\S]*我的家谱/); assert.match(familyHome, /<nav class="profile-path" aria-label="当前位置">[\s\S]*profile-families\.html[\s\S]*data-family-home-context/); - assert.match(familyHomeRuntime, /setText\('\[data-family-home-context\]', result\.overview\.genealogyName\)/); + assert.match(familyHomeRuntime, /setText\('\[data-family-home-context\]', result\.genealogyName\)/); assert.doesNotMatch(coreHeaders, /返回中心|数字家谱/); }); diff --git a/tests/video-pages.test.js b/tests/video-pages.test.js index d07683d..e2f17a9 100644 --- a/tests/video-pages.test.js +++ b/tests/video-pages.test.js @@ -129,6 +129,15 @@ test('视频文件元数据时长按秒取整且拒绝无效值', () => { assert.equal(VideoPages.normalizeDurationSeconds(Infinity), undefined); }); +test('视频维护资格只接受服务端返回的内容或管理布尔能力', () => { + const script = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'video-pages.js'), 'utf8'); + + assert.equal(VideoPages.canEditVideos({ canEditContent: true }), true); + assert.equal(VideoPages.canEditVideos({ canManage: true }), true); + assert.equal(VideoPages.canEditVideos({ canEditContent: 'true', canManage: false }), false); + assert.match(script, /setManagementAccess\(canEditVideos\(result\[0\]\)\)/); +}); + test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID', () => { const root = path.join(__dirname, '..'); const listPage = fs.readFileSync(path.join(root, 'profile-video.html'), 'utf8'); @@ -141,6 +150,9 @@ test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID', assert.match(listPage, /data-video-management hidden/); assert.match(listPage, /src="public\/js\/video-pages\.js"/); assert.match(editPage, /data-video-form data-video-management hidden/); + assert.match(editPage, /data-video-form data-video-management hidden novalidate/); + assert.match(editPage, /class="field-required"/); + assert.match(editPage, /<div class="form-status" data-video-form-status/); assert.match(editPage, /name="videoTitle"/); assert.match(editPage, /name="videoDesc"/); assert.match(editPage, /name="coverOssId" type="hidden"/); @@ -152,3 +164,26 @@ test('视频列表和编辑页开放真实 PC 功能且不允许手填 OSS ID', assert.match(editPage, /public\/js\/video-pages\.js/); assert.match(styles, /\[data-video-management\]\[hidden\]\s*\{[^}]*display:\s*none\s*!important/s); }); + +test('视频编辑将权限读取、校验、保存中和失败状态接入公共状态组件', () => { + const root = path.join(__dirname, '..'); + const listPage = fs.readFileSync(path.join(root, 'profile-video.html'), 'utf8'); + const editPage = fs.readFileSync(path.join(root, 'profile-video-edit.html'), 'utf8'); + const script = fs.readFileSync(path.join(root, 'public', 'js', 'video-pages.js'), 'utf8'); + + assert.match(listPage, /data-video-list[^>]*>[\s\S]*data-api-state="loading"/); + assert.match(listPage, /data-video-detail[^>]*>[\s\S]*data-api-state="empty"/); + assert.match(editPage, /data-api-state="loading"[^>]*data-video-permission-placeholder/); + assert.match(script, /root\.ProfileUI && root\.ProfileUI\.setApiState/); + assert.match(script, /function setPermissionPlaceholder\(type, message\)/); + assert.match(script, /setVideoListState\('loading', '正在加载视频…'\)/); + assert.match(script, /setVideoDetailState\('loading', '正在加载视频详情…'\)/); + assert.match(script, /setVideoListState\('empty', '暂无家族视频'\)/); + assert.match(script, /setVideoListState\('forbidden', '当前账号无权查看该家谱视频。'\)/); + assert.match(script, /setVideoDetailState\(\n\s*getApiStateType\(error\)/); + assert.match(script, /正在读取家谱权限…', 'loading'/); + assert.match(script, /setPermissionPlaceholder\('forbidden', '当前账号没有视频维护权限。'\)/); + assert.match(script, /setFormStatus\(validation, 'error'\)/); + assert.match(script, /setWritePending\(true, '正在保存…'\)/); + assert.match(script, /getApiStateType\(error\)/); +}); diff --git a/tests/vip-pages.test.js b/tests/vip-pages.test.js index 08d6f18..461463f 100644 --- a/tests/vip-pages.test.js +++ b/tests/vip-pages.test.js @@ -112,6 +112,16 @@ test('家谱选择项只接受响应中的稳定 ID 和名称', () => { genealogyId, genealogyName: '' }), null); + assert.deepEqual(VipPages.normalizeGenealogyOptions([ + { genealogyId, genealogyName: '叶氏家谱', surname: '叶' }, + { genealogyId: 'unsafe', genealogyName: '无效家谱' } + ]), [{ genealogyId, genealogyName: '叶氏家谱', surname: '叶' }]); + assert.equal(VipPages.resolveContextGenealogyId([ + { genealogyId, genealogyName: '叶氏家谱', surname: '叶' } + ], genealogyId), genealogyId); + assert.equal(VipPages.resolveContextGenealogyId([ + { genealogyId, genealogyName: '叶氏家谱', surname: '叶' } + ], 'unsafe'), ''); }); test('订单请求只构造 AppVipOrderBody 且页面默认省略支付方式', () => { @@ -216,9 +226,27 @@ test('会员服务页只从接口结果选择套餐和家谱,不暴露业务 I assert.match(source, /<select[^>]+data-vip-genealogy-options/); assert.match(source, /data-vip-order-refresh/); assert.match(source, /data-vip-order-list/); + assert.match(source, /data-vip-order-form novalidate/); + assert.match(source, /data-vip-order-status[^>]*role="status"/); + assert.match(source, /data-api-state="loading"/); assert.doesNotMatch(source, /<input[^>]+name="(?:packageId|genealogyId|orderId)"/); assert.doesNotMatch(source, /name="payType"/); assert.doesNotMatch(source, /立即支付|模拟支付|取消订单|退款/); + assert.ok(source.indexOf('src="public/js/profile-common.js"') < source.indexOf('src="public/js/vip-pages.js"')); +}); + +test('会员服务页补齐桌面端设置与协议入口,不伪造账户接口', () => { + const source = fs.readFileSync(path.join(root, 'profile-services.html'), 'utf8'); + + assert.match(source, /平台设置与协议/); + assert.match(source, /href="about\.html"/); + assert.match(source, /href="privacy\.html"/); + assert.match(source, /href="children-privacy\.html"/); + assert.match(source, /href="terms\.html"/); + assert.match(source, /个性化推荐/); + assert.match(source, /当前 PC 接口未提供推荐偏好的读取或保存/); + assert.doesNotMatch(source, /data-setting-form|data-withdraw-form|data-share-reward/); + assert.doesNotMatch(source, /data-recommendation-toggle|name="recommendation"/); }); test('会员服务运行时并行读取套餐、我的家谱和订单,并在创建后重新读取核验', () => { @@ -229,3 +257,16 @@ test('会员服务运行时并行读取套餐、我的家谱和订单,并在 assert.match(source, /refreshed = normalizeVipOrders\(await api\.vipOrders\(\)\)/); assert.match(source, /item\.orderId === created\.orderId/); }); + +test('会员服务将加载、空数据、提交校验与请求失败写入统一状态组件', () => { + const source = fs.readFileSync(path.join(root, 'public/js/vip-pages.js'), 'utf8'); + + assert.match(source, /function setApiState\(target, type, message\)/); + assert.match(source, /root\.ProfileUI && root\.ProfileUI\.setApiState/); + assert.match(source, /setListState\(packageList, 'loading', '正在读取会员套餐…'\)/); + assert.match(source, /setListState\(orderList, 'empty', '当前没有会员订单。'\)/); + assert.match(source, /setStatus\('error', validation\)/); + assert.match(source, /setPendingButton\(submit, true, '正在创建订单…'\)/); + assert.match(source, /root\.ProfileUI && typeof root\.ProfileUI\.getGenealogyId === 'function'/); + assert.match(source, /已关联当前家谱,请选择会员套餐/); +}); diff --git a/ticket-detail.html b/ticket-detail.html index ded666d..70b5193 100644 --- a/ticket-detail.html +++ b/ticket-detail.html @@ -39,8 +39,10 @@ <section class="site-info-section alt"> <div class="container"> <div class="site-info-list"> - <p class="form-note" aria-live="polite" data-feedback-status></p> - <div data-feedback-detail></div> + <div class="form-status" role="status" aria-live="polite" data-feedback-status></div> + <div data-feedback-detail> + <div class="api-state api-state--loading" data-api-state="loading" role="status" aria-live="polite" aria-busy="true">正在读取反馈详情…</div> + </div> <div class="site-info-actions"> <a class="btn primary magnetic" href="my-tickets.html">返回我的工单</a> <a class="btn ghost magnetic" href="help.html">返回帮助中心</a>