完成80%

This commit is contained in:
2026-07-27 06:50:07 +08:00
parent 8475bbd19a
commit 1eae3bbef4
63 changed files with 13664 additions and 280 deletions
+332 -20
View File
@@ -12,7 +12,7 @@ import {
listNotificationFixtures
} from '@/data/mock.js'
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
import { AUTH_TAC_SCENE, assertSmsCode } from '@/utils/auth-verification.js'
import { AUTH_VERIFICATION_OPERATION, assertSmsCode } from '@/utils/auth-verification.js'
import { GENEALOGY_ACCESS_PRESET, fromApiGenealogyAccess } from '@/utils/genealogy-contracts.js'
import { session } from '@/utils/session.js'
@@ -302,6 +302,64 @@ const requestNativeFileChunk = ({ uploadId, chunkIndex, chunkMd5, filePath }, {
}
})
const requestBrowserFileChunk = ({ uploadId, chunkIndex, chunkMd5, file }, { requestController = null } = {}) => new Promise((resolve, reject) => {
if (typeof globalThis.fetch !== 'function' || typeof globalThis.FormData !== 'function') {
reject(createRequestError('当前运行环境不支持文件上传', 'FILE_UPLOAD_UNAVAILABLE'))
return
}
if (requestController !== null && (
typeof requestController.bind !== 'function' ||
typeof requestController.release !== 'function'
)) {
reject(new TypeError('文件上传控制器格式无效'))
return
}
let settled = false
const abortController = new AbortController()
const release = () => requestController?.release(abort)
const resolveOnce = (value) => {
if (settled) return
settled = true
release()
resolve(value)
}
const rejectOnce = (error) => {
if (settled) return
settled = true
release()
reject(error)
}
const abort = () => {
if (settled) return
abortController.abort()
rejectOnce(createRequestCancelledError())
}
const query = `uploadId=${encodeURIComponent(uploadId)}&chunkIndex=${encodeURIComponent(chunkIndex)}&chunkMd5=${encodeURIComponent(chunkMd5)}`
const token = session.getToken()
const formData = new globalThis.FormData()
formData.append('file', file, file.name || 'image')
if (requestController) requestController.bind(abort)
globalThis.fetch(`${runtimeConfig.baseUrl}/genealogy/app/files/resumable/chunk?${query}`, {
method: 'POST',
headers: {
clientid: runtimeConfig.clientId,
tenantId: runtimeConfig.tenantId,
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: formData,
signal: abortController.signal
}).then(async (response) => {
if (response.status !== 200) {
throw createRequestError(`文件分片上传失败(HTTP ${response.status}`, 'HTTP_ERROR', { httpStatus: response.status })
}
parseStrictUploadResponse(await response.text(), false)
resolveOnce(null)
}).catch((error) => {
if (settled) return
rejectOnce(error?.name === 'AbortError' ? createRequestCancelledError() : error)
})
})
const requestNativeSingleFileUpload = ({ filePath }, { requestController = null } = {}) => new Promise((resolve, reject) => {
if (requestController !== null && (
typeof requestController.bind !== 'function' ||
@@ -371,11 +429,11 @@ const requireRemoteAuth = () => {
}
}
const assertAuthScene = (sceneCode) => {
if (!Object.values(AUTH_TAC_SCENE).includes(sceneCode)) {
throw new TypeError('短信场景不属于当前认证合同')
const assertAuthVerificationOperation = (operationCode) => {
if (!Object.values(AUTH_VERIFICATION_OPERATION).includes(operationCode)) {
throw new TypeError('认证动作不属于当前认证合同')
}
return sceneCode
return operationCode
}
const assertValidToken = (validToken) => {
@@ -385,6 +443,9 @@ const assertValidToken = (validToken) => {
return validToken.trim()
}
const normalizeOptionalValidToken = (validToken) =>
validToken === undefined ? undefined : assertValidToken(validToken)
const assertPasswordHash = (passwordHash) => {
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
throw new TypeError('密码摘要必须是 32 位小写 MD5')
@@ -579,11 +640,13 @@ const normalizeResumableInitResult = (payload) => {
if (typeof payload.instant !== 'boolean') {
throw createRequestError('文件初始化响应缺少 instant', 'FILE_RESPONSE_INVALID')
}
const uploadId = normalizeUploadId(payload.uploadId, '响应 uploadId')
const ossId = payload.ossId === undefined || payload.ossId === null ? null : normalizeUploadOssId(payload.ossId, 'ossId')
if (payload.instant && !ossId) {
throw createRequestError('秒传响应缺少 ossId', 'FILE_RESPONSE_INVALID')
}
const uploadId = payload.instant && (payload.uploadId === undefined || payload.uploadId === null)
? null
: normalizeUploadId(payload.uploadId, '响应 uploadId')
return {
uploadId,
instant: payload.instant,
@@ -844,6 +907,40 @@ const normalizeOptionalNonnegativeInteger = (value, label, code) => {
return value
}
const normalizeGenealogyQuota = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw createRequestError('家谱配额响应无效', 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
const normalizeUsage = (field) => {
if (!Number.isSafeInteger(value[field]) || value[field] < 0) {
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
return value[field]
}
const normalizeLimit = (field) => {
if (!Number.isSafeInteger(value[field]) || value[field] < -1) {
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
return value[field]
}
const normalizePermission = (field) => {
if (typeof value[field] !== 'boolean') {
throw createRequestError(`家谱配额字段 ${field} 无效`, 'GENEALOGY_QUOTA_RESPONSE_INVALID')
}
return value[field]
}
return {
createUsed: normalizeUsage('createUsed'),
createLimit: normalizeLimit('createLimit'),
createRemaining: normalizeLimit('createRemaining'),
canCreate: normalizePermission('canCreate'),
joinUsed: normalizeUsage('joinUsed'),
joinLimit: normalizeLimit('joinLimit'),
joinRemaining: normalizeLimit('joinRemaining'),
canJoin: normalizePermission('canJoin')
}
}
const normalizeOptionalNumericId = (value, label, code) => {
if (value === undefined || value === null || value === '') return null
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
@@ -896,6 +993,31 @@ const normalizeRegionParentCode = (value) => {
return value.trim()
}
const normalizeRegionCode = (value) => {
if (typeof value !== 'string' || !value.trim()) throw new TypeError('行政区划编码无效')
return value.trim()
}
const normalizeRegionSearchPayload = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new TypeError('行政区划搜索参数无效')
}
const keyword = typeof payload.keyword === 'string' ? payload.keyword.trim() : ''
if (!keyword) throw new TypeError('行政区划搜索关键词无效')
const data = { keyword }
if (payload.level !== undefined && payload.level !== null && payload.level !== '') {
if (!Number.isInteger(payload.level) || payload.level < 1 || payload.level > 5) {
throw new TypeError('行政区划级别无效')
}
data.level = payload.level
}
if (payload.limit !== undefined && payload.limit !== null && payload.limit !== '') {
if (!Number.isInteger(payload.limit)) throw new TypeError('行政区划搜索数量上限无效')
data.limit = payload.limit
}
return data
}
const normalizeRegionSelectorItems = (value) => {
if (!Array.isArray(value)) {
throw createRequestError('地区选择响应不是列表', 'REGION_RESPONSE_INVALID')
@@ -1269,7 +1391,7 @@ const normalizeFamilyFeedRows = (value, expectedGenealogyId) => {
mediaOssIds: normalizeOptionalGenealogyText(item.mediaOssIds, 'mediaOssIds'),
likeCount: normalizeOptionalNonnegativeInteger(item.likeCount, '动态点赞数', 'FEED_RESPONSE_INVALID'),
commentCount: normalizeOptionalNonnegativeInteger(item.commentCount, '动态评论数', 'FEED_RESPONSE_INVALID'),
likedByMe: item.likedByMe === true,
likedByMe: typeof item.likedByMe === 'boolean' ? item.likedByMe : null,
pinned: item.pinned === true
}
})
@@ -1666,6 +1788,33 @@ const normalizeJoinAuditPayload = (payload) => {
return data
}
const normalizeCeremonyInviteePayload = (payload) => {
assertPlainPayload(payload, new Set(['inviteeUserIds']), '活动受邀人请求')
if (!Array.isArray(payload.inviteeUserIds)) {
throw new TypeError('inviteeUserIds必须是数组')
}
const inviteeUserIds = payload.inviteeUserIds.map((value) => {
const id = normalizeResourcePathId(value, '受邀业务用户标识')
const numericId = Number(id)
if (!Number.isSafeInteger(numericId)) {
throw createRequestError('受邀业务用户标识超出 APP 安全整数范围,需后端统一字符串 ID 合同', 'CEREMONY_INVITEE_ID_UNSAFE')
}
return numericId
})
if (new Set(inviteeUserIds).size !== inviteeUserIds.length) {
throw new TypeError('inviteeUserIds不能包含重复标识')
}
return { inviteeUserIds }
}
const normalizeCeremonyInvitationResponsePayload = (payload) => {
assertPlainPayload(payload, new Set(['inviteStatus']), '活动邀请响应请求')
if (!['ACCEPTED', 'DECLINED'].includes(payload.inviteStatus)) {
throw new TypeError('inviteStatus仅允许ACCEPTED或DECLINED')
}
return { inviteStatus: payload.inviteStatus }
}
const normalizeGenerationPoemWritePayload = (payload) => {
assertPlainPayload(payload, new Set(['generationNo', 'generationText', 'description', 'sortOrder', 'status']), '字辈请求')
const generationNo = Number(payload.generationNo)
@@ -1798,9 +1947,15 @@ const normalizeLineageWritePayload = (payload) => {
throw new TypeError('人物写入请求必须是普通对象')
}
const allowedFields = new Set([
'appUserId',
'personNo',
'name',
'aliasName',
'sex',
'generationName',
'fatherId',
'motherId',
'avatarOssId',
'birthDate',
'birthLunar',
'birthPlace',
@@ -1811,7 +1966,9 @@ const normalizeLineageWritePayload = (payload) => {
'biography',
'remark',
'relationName',
'generation'
'generation',
'personStatus',
'sortOrder'
])
for (const field of Object.keys(payload)) {
if (!allowedFields.has(field)) throw new TypeError(`人物写入包含未声明字段:${field}`)
@@ -1819,6 +1976,15 @@ const normalizeLineageWritePayload = (payload) => {
const name = normalizeLineagePersonText(payload.name, '姓名', { required: true })
if (name.length > 20) throw new TypeError('人物姓名长度超出当前页面合同')
const data = { name }
for (const [field, label] of [
['appUserId', '绑定用户标识'],
['fatherId', '父亲标识'],
['motherId', '母亲标识'],
['avatarOssId', '头像文件标识']
]) {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
data[field] = normalizeLineagePersonIdentity(payload[field], label)
}
if (payload.generation !== undefined) {
if (!Number.isSafeInteger(payload.generation) || payload.generation < 1) {
throw new TypeError('人物世代必须是正安全整数')
@@ -1826,6 +1992,8 @@ const normalizeLineageWritePayload = (payload) => {
data.generation = payload.generation
}
for (const [field, label, maxLength] of [
['personNo', '人物编号', null],
['sex', '性别', null],
['generationName', '字辈', 12],
['aliasName', '别名或曾用名', null],
['birthLunar', '出生农历', null],
@@ -1835,7 +2003,8 @@ const normalizeLineageWritePayload = (payload) => {
['burialPlace', '安葬地', null],
['biography', '人物简介', 500],
['remark', '备注', null],
['relationName', '关系显示名称', null]
['relationName', '关系显示名称', null],
['personStatus', '人物状态', null]
]) {
if (payload[field] === undefined) continue
const value = normalizeLineagePersonText(payload[field], label)
@@ -1853,6 +2022,11 @@ const normalizeLineageWritePayload = (payload) => {
if (data.birthDate && data.deathDate && data.deathDate < data.birthDate) {
throw new TypeError('人物离世日期不能早于出生日期')
}
if (payload.sortOrder !== undefined && payload.sortOrder !== null && payload.sortOrder !== '') {
const sortOrder = typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder)
if (!Number.isSafeInteger(sortOrder)) throw new TypeError('人物排序值必须是安全整数')
data.sortOrder = sortOrder
}
return data
}
@@ -1877,33 +2051,55 @@ const requireLineageWriteRequestController = (requestOptions) => {
}
export const appApi = {
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
async getCaptchaRequirement({ operationCode, subject }, requestOptions = {}) {
requireRemoteAuth()
return requestAuth({
url: '/captcha/require',
url: `/genealogy/app/auth/verification/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/require`,
method: 'GET',
data: authPayload({ sceneCode: assertAuthScene(sceneCode), subject })
data: { tenantId: runtimeConfig.tenantId, subject }
}, requestOptions)
},
async sendSmsCode({ sceneCode, phone, validToken }, requestOptions = {}) {
async sendSmsCode({ operationCode, phone, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
return requestAuthVoid({
url: `/genealogy/app/auth/sms/${encodeURIComponent(assertAuthVerificationOperation(operationCode))}/code`,
method: 'POST',
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'sms',
phone,
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
}
}, requestOptions)
},
async sendLegacySmsCode({ phone, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
return requestAuthVoid({
url: '/genealogy/app/auth/sms/code',
method: 'POST',
data: authPayload({
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'sms',
sceneCode: assertAuthScene(sceneCode),
phone,
validToken: assertValidToken(validToken)
})
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
}
}, requestOptions)
},
async loginWithPassword({ phone, passwordHash }, requestOptions = {}) {
async loginWithPassword({ phone, passwordHash, validToken }, requestOptions = {}) {
requireRemoteAuth()
const normalizedValidToken = normalizeOptionalValidToken(validToken)
const result = await requestAuth({
url: '/genealogy/app/auth/login',
method: 'POST',
data: authPayload({ grantType: 'password', phone, password: assertPasswordHash(passwordHash) })
data: {
tenantId: runtimeConfig.tenantId,
grantType: 'password',
phone,
password: assertPasswordHash(passwordHash),
...(normalizedValidToken ? { validToken: normalizedValidToken } : {})
}
}, requestOptions)
return saveLogin(result)
},
@@ -2069,6 +2265,25 @@ export const appApi = {
filePath: payload.filePath.trim()
}, requestOptions)
},
async uploadBrowserResumableChunk(payload, file, requestOptions = {}) {
const allowedFields = new Set(['uploadId', 'chunkIndex', 'chunkMd5'])
assertPlainPayload(payload, allowedFields, '浏览器文件分片请求')
if (!Number.isInteger(payload.chunkIndex) || payload.chunkIndex < 0 || payload.chunkIndex > 2147483647) {
throw new TypeError('chunkIndex必须是非负 int32')
}
if (!file || typeof file !== 'object') {
throw new TypeError('浏览器文件分片必须提供文件对象')
}
if (!hasRemoteConfig()) {
throw createRequestError('文件上传需要真实服务,当前本地预览不伪造回执', 'REMOTE_WRITE_REQUIRED')
}
return requestBrowserFileChunk({
uploadId: normalizeUploadId(payload.uploadId, 'uploadId'),
chunkIndex: payload.chunkIndex,
chunkMd5: normalizeUploadMd5(payload.chunkMd5, 'chunkMd5'),
file
}, requestOptions)
},
async completeResumableUpload(payload, requestOptions = {}) {
const data = normalizeResumableCompletePayload(payload)
if (!hasRemoteConfig()) {
@@ -2111,7 +2326,7 @@ export const appApi = {
throw error
}
const result = await requestStrict({
url: '/genealogy/app/region/children',
url: '/genealogy/region/children',
method: 'GET',
data: { parentCode: normalizeRegionParentCode(parentCode) }
}, {
@@ -2119,6 +2334,46 @@ export const appApi = {
})
return normalizeRegionSelectorItems(result)
},
async getRegionPath(regionCode, requestOptions = {}) {
requireRemoteResource('行政区划路径', '读取')
const result = await requestStrict({
url: `/genealogy/region/path/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
if (!Array.isArray(result)) {
throw createRequestError('行政区划路径响应不是列表', 'REGION_PATH_RESPONSE_INVALID')
}
return result
},
async searchRegions(payload, requestOptions = {}) {
requireRemoteResource('行政区划搜索', '读取')
const result = await requestStrict({
url: '/genealogy/region/search',
method: 'GET',
data: normalizeRegionSearchPayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
if (!Array.isArray(result)) {
throw createRequestError('行政区划搜索响应不是列表', 'REGION_SEARCH_RESPONSE_INVALID')
}
return result
},
async getRegion(regionCode, requestOptions = {}) {
requireRemoteResource('行政区划详情', '读取')
const result = await requestStrict({
url: `/genealogy/region/${encodeURIComponent(normalizeRegionCode(regionCode))}`,
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw createRequestError('行政区划详情响应不是对象', 'REGION_DETAIL_RESPONSE_INVALID')
}
return result
},
async getMyGenealogies(requestOptions = {}) {
if (!hasRemoteConfig()) {
const error = new Error('我的家谱需要真实读取服务,当前本地预览不会伪造家谱列表')
@@ -2133,6 +2388,16 @@ export const appApi = {
})
return normalizeMyGenealogies(result)
},
async getGenealogyQuota(requestOptions = {}) {
requireRemoteResource('家谱配额', '读取')
const result = await requestStrict({
url: '/genealogy/app/genealogies/quota',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
return normalizeGenealogyQuota(result)
},
async createGenealogy(payload, requestOptions = {}) {
const data = normalizeGenealogyCreatePayload(payload)
if (!hasRemoteConfig()) {
@@ -2820,6 +3085,40 @@ export const appApi = {
if (!hasRemoteConfig()) return listCeremonyFixtures(normalizedGenealogyId)
return readRemoteList(`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies`, '礼仪活动', requestOptions)
},
async replaceCeremonyInvitees(genealogyId, ceremonyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
return writeRemoteObject(
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitees`,
'PUT',
normalizeCeremonyInviteePayload(payload),
'活动受邀人',
requestOptions
)
},
async getCeremonyInvitations(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
return readRemoteList(
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations`,
'活动邀请名单',
requestOptions
)
},
async respondToCeremonyInvitation(genealogyId, ceremonyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
return writeRemoteObject(
`/genealogy/app/genealogies/${normalizedGenealogyId}/ceremonies/${normalizedCeremonyId}/invitations/me`,
'PUT',
normalizeCeremonyInvitationResponsePayload(payload),
'活动邀请响应',
requestOptions
)
},
async getMyCeremonyInvitations(requestOptions = {}) {
return readRemoteList('/genealogy/app/genealogies/ceremony-invitations/mine', '我的活动邀请', requestOptions)
},
async getCeremonyDetail(genealogyId, ceremonyId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedCeremonyId = normalizeFeedCommentId(ceremonyId, '礼仪活动标识')
@@ -3014,6 +3313,19 @@ export const appApi = {
if (!hasRemoteConfig()) return listNotificationFixtures()
return readRemoteList('/genealogy/app/notifications', '消息通知', requestOptions)
},
async getUnreadNotificationCount(requestOptions = {}) {
requireRemoteResource('未读通知数量', '读取')
const value = await requestStrict({
url: '/genealogy/app/notifications/unread-count',
method: 'GET'
}, {
requestController: requestOptions.requestController ?? null
})
if (!Number.isSafeInteger(value) || value < 0) {
throw createRequestError('未读通知数量响应无效', 'NOTIFICATION_COUNT_RESPONSE_INVALID')
}
return value
},
async markNotificationRead(notificationId, requestOptions = {}) {
const normalizedNotificationId = normalizeResourcePathId(notificationId, '通知标识')
if (hasRemoteConfig()) {