完成50%
This commit is contained in:
+345
-65
@@ -1,21 +1,107 @@
|
||||
import { currentUser, genealogies, treeMembers, familyFeeds, familyContent, notifications, joinApplications } from '@/data/mock.js'
|
||||
import { hasRemoteConfig, isMockMode, runtimeConfig } from '@/utils/config.js'
|
||||
import {
|
||||
currentUser,
|
||||
genealogies,
|
||||
publicGenealogies,
|
||||
treeMembers,
|
||||
notifications,
|
||||
joinApplications,
|
||||
listFamilyFeedFixtures,
|
||||
listFamilyArticleFixtures,
|
||||
listFamilyAlbumFixtures,
|
||||
listCeremonyFixtures,
|
||||
listGrowthRecordFixtures,
|
||||
listNotificationFixtures
|
||||
} from '@/data/mock.js'
|
||||
import { hasRemoteConfig, resolveRuntimeMode, runtimeConfig } from '@/utils/config.js'
|
||||
import { AUTH_TAC_SCENE, assertSmsCode } from '@/utils/auth-verification.js'
|
||||
import { GENEALOGY_ACCESS_PRESET } from '@/utils/genealogy-contracts.js'
|
||||
import { session } from '@/utils/session.js'
|
||||
|
||||
const successCodes = [0, 200]
|
||||
const REQUEST_TIMEOUT_MS = 15000
|
||||
|
||||
const createRequestError = (message, code, details = {}) => {
|
||||
const error = new Error(message)
|
||||
error.code = code
|
||||
Object.assign(error, details)
|
||||
return error
|
||||
}
|
||||
|
||||
const createRequestCancelledError = () => createRequestError('请求已取消', 'REQUEST_CANCELLED')
|
||||
|
||||
// 页面只持有这个窄控制器,不直接依赖各端 RequestTask 的实现差异。
|
||||
// request() 是唯一绑定/释放 owner;离页时 abort() 会同时拒绝等待中的 Promise,
|
||||
// 因而页面不会在已销毁后继续处理成功回调。
|
||||
export const createRequestController = () => {
|
||||
let abortCurrent = null
|
||||
return {
|
||||
bind(abortRequest) {
|
||||
if (typeof abortRequest !== 'function') throw new TypeError('请求中止器必须是函数')
|
||||
if (abortCurrent) abortCurrent()
|
||||
abortCurrent = abortRequest
|
||||
},
|
||||
release(abortRequest) {
|
||||
if (abortCurrent === abortRequest) abortCurrent = null
|
||||
},
|
||||
abort() {
|
||||
const abortRequest = abortCurrent
|
||||
abortCurrent = null
|
||||
if (abortRequest) abortRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isRequestCancelled = (error) => error?.code === 'REQUEST_CANCELLED'
|
||||
|
||||
export const unwrapResponse = (response) => {
|
||||
if (!response || typeof response !== 'object' || Array.isArray(response)) return response
|
||||
if (!Object.prototype.hasOwnProperty.call(response, 'code')) return response
|
||||
if (!successCodes.includes(Number(response.code))) {
|
||||
throw new Error(response.msg || '请求未成功')
|
||||
const numericCode = Number(response.code)
|
||||
throw createRequestError(response.msg || '请求未成功', 'BUSINESS_ERROR', {
|
||||
businessCode: Number.isNaN(numericCode) ? response.code : numericCode
|
||||
})
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(response, 'data') ? response.data : response
|
||||
}
|
||||
|
||||
const request = (options, { authenticated = true } = {}) => new Promise((resolve, reject) => {
|
||||
const request = (options, {
|
||||
authenticated = true,
|
||||
strictEnvelope = false,
|
||||
requireEnvelopeData = false,
|
||||
expectedStatus = null,
|
||||
requestController = null
|
||||
} = {}) => new Promise((resolve, reject) => {
|
||||
if (requestController !== null && (
|
||||
typeof requestController.bind !== 'function' ||
|
||||
typeof requestController.release !== 'function'
|
||||
)) {
|
||||
reject(new TypeError('请求控制器格式无效'))
|
||||
return
|
||||
}
|
||||
let settled = false
|
||||
let requestTask = null
|
||||
const release = () => requestController?.release(abortRequest)
|
||||
const resolveOnce = (value) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
release()
|
||||
resolve(value)
|
||||
}
|
||||
const rejectOnce = (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
release()
|
||||
reject(error)
|
||||
}
|
||||
const abortRequest = () => {
|
||||
if (settled) return
|
||||
const task = requestTask
|
||||
rejectOnce(createRequestCancelledError())
|
||||
task?.abort?.()
|
||||
}
|
||||
const token = session.getToken()
|
||||
uni.request({
|
||||
requestTask = uni.request({
|
||||
...options,
|
||||
url: `${runtimeConfig.baseUrl}${options.url}`,
|
||||
header: {
|
||||
@@ -24,18 +110,49 @@ const request = (options, { authenticated = true } = {}) => new Promise((resolve
|
||||
...(options.header || {})
|
||||
},
|
||||
success: ({ data, statusCode }) => {
|
||||
if (expectedStatus !== null && statusCode !== expectedStatus) {
|
||||
rejectOnce(createRequestError(
|
||||
`服务只接受 HTTP ${expectedStatus} 响应,实际为 ${statusCode}`,
|
||||
'HTTP_ERROR',
|
||||
{ httpStatus: statusCode }
|
||||
))
|
||||
return
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(data?.msg || `请求失败(${statusCode})`))
|
||||
rejectOnce(createRequestError(
|
||||
data?.msg || `请求失败(${statusCode})`,
|
||||
'HTTP_ERROR',
|
||||
{ httpStatus: statusCode }
|
||||
))
|
||||
return
|
||||
}
|
||||
if (
|
||||
strictEnvelope &&
|
||||
(!data ||
|
||||
typeof data !== 'object' ||
|
||||
Array.isArray(data) ||
|
||||
!Object.prototype.hasOwnProperty.call(data, 'code') ||
|
||||
!Number.isInteger(data.code) ||
|
||||
(requireEnvelopeData && !Object.prototype.hasOwnProperty.call(data, 'data')))
|
||||
) {
|
||||
rejectOnce(createRequestError('服务响应格式无效', 'RESPONSE_INVALID'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(unwrapResponse(data))
|
||||
resolveOnce(unwrapResponse(data))
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
rejectOnce(error)
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(new Error(error.errMsg || '网络连接失败'))
|
||||
fail: (error) => {
|
||||
const message = error?.errMsg || '网络连接失败'
|
||||
rejectOnce(createRequestError(
|
||||
message,
|
||||
/timeout/i.test(message) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR'
|
||||
))
|
||||
}
|
||||
})
|
||||
if (!settled && requestController) requestController.bind(abortRequest)
|
||||
})
|
||||
|
||||
const authPayload = (payload) => ({
|
||||
@@ -44,8 +161,98 @@ const authPayload = (payload) => ({
|
||||
...payload
|
||||
})
|
||||
|
||||
// 已核对接口使用这一严格边界:只接受 HTTP 200 与完整 JSON envelope,
|
||||
// 并统一限制弱网等待时间。尚未逐页治理的旧业务读取仍沿用上方通用 request。
|
||||
const requestStrict = (options, {
|
||||
authenticated = true,
|
||||
requireData = true,
|
||||
requestController = null
|
||||
} = {}) => request({
|
||||
...options,
|
||||
timeout: REQUEST_TIMEOUT_MS
|
||||
}, {
|
||||
authenticated,
|
||||
strictEnvelope: true,
|
||||
requireEnvelopeData: requireData,
|
||||
expectedStatus: 200,
|
||||
requestController
|
||||
})
|
||||
|
||||
const requestAuth = (options, { requestController = null } = {}) => requestStrict(options, {
|
||||
authenticated: false,
|
||||
requestController
|
||||
})
|
||||
|
||||
// RVoid 的 OpenAPI schema 没有把 data 声明为必填。空结果接口仍要求合法的
|
||||
// HTTP 200 与整数 code,但统一向页面返回 null,避免把 envelope 本身泄漏成业务数据。
|
||||
const requestAuthVoid = async (options, { requestController = null } = {}) => {
|
||||
await requestStrict(options, {
|
||||
authenticated: false,
|
||||
requireData: false,
|
||||
requestController
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const requireRemoteAuth = () => {
|
||||
if (resolveRuntimeMode() !== 'remote') {
|
||||
const error = new Error('当前为本地预览模式,真实认证服务未启用')
|
||||
error.code = 'AUTH_REMOTE_REQUIRED'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const assertAuthScene = (sceneCode) => {
|
||||
if (!Object.values(AUTH_TAC_SCENE).includes(sceneCode)) {
|
||||
throw new TypeError('短信场景不属于当前认证合同')
|
||||
}
|
||||
return sceneCode
|
||||
}
|
||||
|
||||
const assertValidToken = (validToken) => {
|
||||
if (typeof validToken !== 'string' || !validToken.trim()) {
|
||||
throw new TypeError('发送短信前必须取得有效的行为验证票据')
|
||||
}
|
||||
return validToken.trim()
|
||||
}
|
||||
|
||||
const assertPasswordHash = (passwordHash) => {
|
||||
if (typeof passwordHash !== 'string' || !/^[a-f0-9]{32}$/.test(passwordHash)) {
|
||||
throw new TypeError('密码摘要必须是 32 位小写 MD5')
|
||||
}
|
||||
return passwordHash
|
||||
}
|
||||
|
||||
const normalizeFeedbackPayload = (payload) => {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.getPrototypeOf(payload) !== Object.prototype) {
|
||||
throw new TypeError('反馈请求必须是普通对象')
|
||||
}
|
||||
const allowedFields = new Set(['feedbackType', 'feedbackContent', 'contactInfo'])
|
||||
const fieldNames = Object.getOwnPropertyNames(payload)
|
||||
if (Object.getOwnPropertySymbols(payload).length > 0 || fieldNames.some((field) => !allowedFields.has(field))) {
|
||||
throw new TypeError('反馈请求包含未声明字段')
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(payload)
|
||||
if (fieldNames.some((field) => !Object.prototype.hasOwnProperty.call(descriptors[field], 'value'))) {
|
||||
throw new TypeError('反馈字段必须是普通数据属性')
|
||||
}
|
||||
if (typeof payload.feedbackContent !== 'string' || !payload.feedbackContent.trim()) {
|
||||
throw new TypeError('反馈内容必须是非空字符串')
|
||||
}
|
||||
const normalized = { feedbackContent: payload.feedbackContent.trim() }
|
||||
for (const optionalField of ['feedbackType', 'contactInfo']) {
|
||||
if (!Object.prototype.hasOwnProperty.call(payload, optionalField)) continue
|
||||
if (typeof payload[optionalField] !== 'string') {
|
||||
throw new TypeError(`反馈字段 ${optionalField} 必须是字符串`)
|
||||
}
|
||||
const value = payload[optionalField].trim()
|
||||
if (value) normalized[optionalField] = value
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
const saveLogin = (loginResult) => {
|
||||
const token = loginResult?.token || loginResult?.accessToken || loginResult?.tokenValue
|
||||
const token = loginResult?.access_token
|
||||
if (!token) throw new Error('登录响应未包含会话令牌')
|
||||
session.saveToken(token)
|
||||
return loginResult
|
||||
@@ -55,6 +262,9 @@ const toTreeNode = (person, index = 0) => {
|
||||
const generation = Number(person.generationNo || person.generation || 1)
|
||||
return {
|
||||
...person,
|
||||
relatives: Array.isArray(person.relatives)
|
||||
? person.relatives.map((relative) => ({ ...relative }))
|
||||
: [],
|
||||
id: person.id || person.personId,
|
||||
name: person.personName || person.name || '未命名族人',
|
||||
relation: person.relation || (generation === 1 ? '始祖' : '族人'),
|
||||
@@ -67,38 +277,99 @@ const toTreeNode = (person, index = 0) => {
|
||||
}
|
||||
|
||||
export const appApi = {
|
||||
async sendSmsCode({ phone, validToken = '' }) {
|
||||
if (isMockMode()) return { success: true }
|
||||
return request({
|
||||
async getCaptchaRequirement({ sceneCode, subject }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
return requestAuth({
|
||||
url: '/captcha/require',
|
||||
method: 'GET',
|
||||
data: authPayload({ sceneCode: assertAuthScene(sceneCode), subject })
|
||||
}, requestOptions)
|
||||
},
|
||||
async sendSmsCode({ sceneCode, phone, validToken }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
return requestAuthVoid({
|
||||
url: '/genealogy/app/auth/sms/code',
|
||||
method: 'POST',
|
||||
data: authPayload({ grantType: 'sms', sceneCode: 'APP_LOGIN', phone, validToken })
|
||||
}, { authenticated: false })
|
||||
data: authPayload({
|
||||
grantType: 'sms',
|
||||
sceneCode: assertAuthScene(sceneCode),
|
||||
phone,
|
||||
validToken: assertValidToken(validToken)
|
||||
})
|
||||
}, requestOptions)
|
||||
},
|
||||
async loginWithPassword({ phone, passwordHash }) {
|
||||
if (isMockMode()) {
|
||||
session.saveToken('mock-session-token')
|
||||
return { token: 'mock-session-token', userId: currentUser.id }
|
||||
}
|
||||
const result = await request({
|
||||
async loginWithPassword({ phone, passwordHash }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
const result = await requestAuth({
|
||||
url: '/genealogy/app/auth/login',
|
||||
method: 'POST',
|
||||
data: authPayload({ grantType: 'password', phone, password: passwordHash })
|
||||
}, { authenticated: false })
|
||||
data: authPayload({ grantType: 'password', phone, password: assertPasswordHash(passwordHash) })
|
||||
}, requestOptions)
|
||||
return saveLogin(result)
|
||||
},
|
||||
async loginWithSms({ phone, smsCode }) {
|
||||
if (isMockMode()) {
|
||||
session.saveToken('mock-session-token')
|
||||
return { token: 'mock-session-token', userId: currentUser.id }
|
||||
}
|
||||
const result = await request({
|
||||
async loginWithSms({ phone, smsCode }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
const result = await requestAuth({
|
||||
url: '/genealogy/app/auth/login/sms',
|
||||
method: 'POST',
|
||||
data: authPayload({ grantType: 'sms', phone, smsCode })
|
||||
}, { authenticated: false })
|
||||
data: authPayload({ grantType: 'sms', phone, smsCode: assertSmsCode(smsCode) })
|
||||
}, requestOptions)
|
||||
return saveLogin(result)
|
||||
},
|
||||
async registerWithPassword({ phone, passwordHash, smsCode }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
const result = await requestAuth({
|
||||
url: '/genealogy/app/auth/register',
|
||||
method: 'POST',
|
||||
data: authPayload({
|
||||
grantType: 'password',
|
||||
phone,
|
||||
password: assertPasswordHash(passwordHash),
|
||||
smsCode: assertSmsCode(smsCode)
|
||||
})
|
||||
}, requestOptions)
|
||||
return saveLogin(result)
|
||||
},
|
||||
async resetPassword({ phone, passwordHash, smsCode }, requestOptions = {}) {
|
||||
requireRemoteAuth()
|
||||
return requestAuthVoid({
|
||||
url: '/genealogy/app/auth/password/reset',
|
||||
method: 'PUT',
|
||||
data: authPayload({
|
||||
grantType: 'password',
|
||||
phone,
|
||||
newPassword: assertPasswordHash(passwordHash),
|
||||
smsCode: assertSmsCode(smsCode)
|
||||
})
|
||||
}, requestOptions)
|
||||
},
|
||||
async submitFeedback(payload, requestOptions = {}) {
|
||||
const data = normalizeFeedbackPayload(payload)
|
||||
if (!requestOptions || typeof requestOptions !== 'object' || Array.isArray(requestOptions) || Object.getPrototypeOf(requestOptions) !== Object.prototype) {
|
||||
throw new TypeError('反馈请求选项必须是普通对象')
|
||||
}
|
||||
const optionNames = Object.getOwnPropertyNames(requestOptions)
|
||||
const optionDescriptors = Object.getOwnPropertyDescriptors(requestOptions)
|
||||
if (
|
||||
Object.getOwnPropertySymbols(requestOptions).length > 0 ||
|
||||
optionNames.some((field) => field !== 'requestController') ||
|
||||
optionNames.some((field) => !Object.prototype.hasOwnProperty.call(optionDescriptors[field], 'value'))
|
||||
) {
|
||||
throw new TypeError('反馈请求选项包含未声明字段')
|
||||
}
|
||||
const runtimeMode = resolveRuntimeMode()
|
||||
if (runtimeMode !== 'remote') {
|
||||
throw createRequestError('当前为本地预览模式,反馈未提交服务器', 'WRITE_UNAVAILABLE')
|
||||
}
|
||||
return requestStrict({
|
||||
url: '/genealogy/app/feedback',
|
||||
method: 'POST',
|
||||
data
|
||||
}, {
|
||||
requireData: false,
|
||||
requestController: requestOptions.requestController ?? null
|
||||
})
|
||||
},
|
||||
async getProfile() {
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/auth/profile' }) : currentUser
|
||||
},
|
||||
@@ -107,7 +378,10 @@ export const appApi = {
|
||||
},
|
||||
async createGenealogy(payload) {
|
||||
if (hasRemoteConfig()) return request({ url: '/genealogy/app/genealogies', method: 'POST', data: payload })
|
||||
const created = { ...payload, id: Date.now(), memberCount: 0, activeCount: 0, motto: '敦亲睦族,敬祖传家。', visibility: '仅成员可见' }
|
||||
const accessPreset = Object.values(GENEALOGY_ACCESS_PRESET).includes(payload.accessPreset)
|
||||
? payload.accessPreset
|
||||
: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY
|
||||
const created = { ...payload, id: String(Date.now()), memberCount: 0, activeCount: 0, motto: '敦亲睦族,敬祖传家。', accessPreset, membership: 'created' }
|
||||
genealogies.unshift(created)
|
||||
return created
|
||||
},
|
||||
@@ -122,14 +396,19 @@ export const appApi = {
|
||||
const tree = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/tree` })
|
||||
return (tree || []).map(toTreeNode)
|
||||
}
|
||||
return treeMembers.filter((item) => String(item.genealogyId || 1001) === String(genealogyId)).map(toTreeNode)
|
||||
return treeMembers
|
||||
.filter((item) => String(item.genealogyId) === String(genealogyId))
|
||||
.map(toTreeNode)
|
||||
},
|
||||
async getPerson(genealogyId, personId) {
|
||||
if (hasRemoteConfig()) {
|
||||
const person = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/persons/${personId}` })
|
||||
return toTreeNode(person)
|
||||
}
|
||||
const person = treeMembers.find((item) => String(item.id) === String(personId) && String(item.genealogyId || 1001) === String(genealogyId))
|
||||
const person = treeMembers.find(
|
||||
(item) => String(item.id) === String(personId) &&
|
||||
String(item.genealogyId) === String(genealogyId)
|
||||
)
|
||||
return person ? toTreeNode(person) : null
|
||||
},
|
||||
async createPerson(genealogyId, payload) {
|
||||
@@ -137,42 +416,36 @@ export const appApi = {
|
||||
const person = await request({ url: `/genealogy/app/genealogies/${genealogyId}/lineage/persons`, method: 'POST', data: payload })
|
||||
return toTreeNode(person)
|
||||
}
|
||||
const person = toTreeNode({
|
||||
...payload,
|
||||
id: Date.now(),
|
||||
genealogyId,
|
||||
relation: Number(payload.generationNo) === 1 ? '始祖' : '族人',
|
||||
x: 50,
|
||||
y: 7
|
||||
})
|
||||
treeMembers.push(person)
|
||||
const genealogy = genealogies.find((item) => String(item.id) === String(genealogyId))
|
||||
if (genealogy) {
|
||||
genealogy.memberCount = (genealogy.memberCount || 0) + 1
|
||||
genealogy.activeCount = (genealogy.activeCount || 0) + 1
|
||||
}
|
||||
return person
|
||||
const error = new Error('人物创建接口在本地预览模式不可用,当前内容不会保存')
|
||||
error.code = 'WRITE_UNAVAILABLE'
|
||||
throw error
|
||||
},
|
||||
async getFeeds(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds` }) : familyFeeds
|
||||
return hasRemoteConfig()
|
||||
? request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds` })
|
||||
: listFamilyFeedFixtures(genealogyId)
|
||||
},
|
||||
async createFeed(genealogyId, payload) {
|
||||
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/feeds`, method: 'POST', data: payload })
|
||||
const feed = { id: Date.now(), genealogyId, author: currentUser.name, title: '家族动态', content: payload.feedContent, date: '刚刚', type: '族务' }
|
||||
familyFeeds.unshift(feed)
|
||||
return feed
|
||||
const error = new Error('动态发布接口尚未接入,当前内容不会保存')
|
||||
error.code = 'WRITE_UNAVAILABLE'
|
||||
throw error
|
||||
},
|
||||
async getArticles(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/articles` }) : familyContent.article
|
||||
return hasRemoteConfig()
|
||||
? request({ url: `/genealogy/app/genealogies/${genealogyId}/articles` })
|
||||
: listFamilyArticleFixtures(genealogyId)
|
||||
},
|
||||
async getAlbums(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/albums` }) : familyContent.album
|
||||
return hasRemoteConfig()
|
||||
? request({ url: `/genealogy/app/genealogies/${genealogyId}/albums` })
|
||||
: listFamilyAlbumFixtures(genealogyId)
|
||||
},
|
||||
async getCeremonies(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/ceremonies` }) : familyContent.ceremony
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/ceremonies` }) : listCeremonyFixtures(genealogyId)
|
||||
},
|
||||
async getGrowthRecords(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/growth-records` }) : familyContent.record
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/growth-records` }) : listGrowthRecordFixtures(genealogyId)
|
||||
},
|
||||
async getContent(type, genealogyId) {
|
||||
const loaders = { article: this.getArticles, album: this.getAlbums, ceremony: this.getCeremonies, record: this.getGrowthRecords }
|
||||
@@ -180,7 +453,7 @@ export const appApi = {
|
||||
return loaders[type].call(this, genealogyId)
|
||||
},
|
||||
async getNotifications() {
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/notifications' }) : notifications
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/notifications' }) : listNotificationFixtures()
|
||||
},
|
||||
async markNotificationRead(notificationId) {
|
||||
if (hasRemoteConfig()) return request({ url: `/genealogy/app/notifications/${notificationId}/read`, method: 'POST' })
|
||||
@@ -194,16 +467,17 @@ export const appApi = {
|
||||
return { success: true }
|
||||
},
|
||||
async getPublicGenealogies() {
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/genealogies/public' }) : genealogies.filter((item) => item.visibility === '公开可申请')
|
||||
return hasRemoteConfig() ? request({ url: '/genealogy/app/genealogies/public' }) : publicGenealogies
|
||||
},
|
||||
async applyToJoin(genealogyId, payload) {
|
||||
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies`, method: 'POST', data: payload })
|
||||
joinApplications.unshift({
|
||||
id: Date.now(),
|
||||
id: String(Date.now()),
|
||||
genealogyId,
|
||||
name: currentUser.name,
|
||||
phone: currentUser.phone,
|
||||
relation: payload.message || '申请加入家谱',
|
||||
name: payload.applicantName || currentUser.name,
|
||||
phone: payload.phone || currentUser.phone,
|
||||
relation: payload.relationDesc || '关系待补充',
|
||||
reason: payload.applyReason || '',
|
||||
appliedAt: '刚刚',
|
||||
status: 'PENDING'
|
||||
})
|
||||
@@ -212,7 +486,13 @@ export const appApi = {
|
||||
async getPendingApplications(genealogyId) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies/pending` }) : joinApplications.filter((item) => String(item.genealogyId || 1001) === String(genealogyId))
|
||||
},
|
||||
async auditApplication(genealogyId, applicationId, approved) {
|
||||
return hasRemoteConfig() ? request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies/${applicationId}/audit`, method: 'PUT', data: { approved } }) : { success: true }
|
||||
async auditApplication(genealogyId, applicationId, { status, auditRemark = '' }) {
|
||||
if (typeof status !== 'string' || !/^[12]$/.test(status)) throw new TypeError('审核状态只能是 1 或 2')
|
||||
if (typeof auditRemark !== 'string') throw new TypeError('审核备注必须是字符串')
|
||||
if (Array.from(auditRemark).length > 500) throw new RangeError('审核备注不能超过 500 个字符')
|
||||
if (hasRemoteConfig()) return request({ url: `/genealogy/app/genealogies/${genealogyId}/join-applies/${applicationId}/audit`, method: 'PUT', data: { status, auditRemark } })
|
||||
const application = joinApplications.find((item) => String(item.id) === String(applicationId))
|
||||
if (application) Object.assign(application, { status, auditRemark })
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
export const AUTH_TAC_SCENE = Object.freeze({
|
||||
SMS_LOGIN: "APP_SMS_LOGIN",
|
||||
REGISTER: "APP_REGISTER",
|
||||
FORGOT_PASSWORD: "APP_FORGOT_PASSWORD",
|
||||
});
|
||||
|
||||
export const PASSWORD_TAC_BLOCKED_MESSAGE =
|
||||
"密码登录的服务端安全验证尚未开放,请先使用验证码登录";
|
||||
|
||||
const SUPPORTED_TAC_TYPES = new Set([
|
||||
"SLIDER",
|
||||
"ROTATE",
|
||||
"CONCAT",
|
||||
"WORD_IMAGE_CLICK",
|
||||
]);
|
||||
const PHONE_PATTERN = /^1[3-9]\d{9}$/;
|
||||
|
||||
export const isAuthPhone = (value) =>
|
||||
typeof value === "string" && PHONE_PATTERN.test(value);
|
||||
|
||||
const contractError = (message, code) => {
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
return error;
|
||||
};
|
||||
|
||||
const requireText = (value, label) => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw contractError(`${label}不能为空`, "AUTH_TAC_CONTRACT_INVALID");
|
||||
}
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
export const assertSmsCode = (value) => {
|
||||
if (typeof value !== "string" || !/^\d{4}$/.test(value)) {
|
||||
throw contractError("请输入 4 位短信验证码", "SMS_CODE_INVALID");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const normalizeCaptchaRequirement = (value, expectedSceneCode) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw contractError("行为验证策略响应无效", "AUTH_TAC_REQUIREMENT_INVALID");
|
||||
}
|
||||
const sceneCode = requireText(value.sceneCode, "行为验证场景");
|
||||
if (sceneCode !== expectedSceneCode) {
|
||||
throw contractError("行为验证场景与当前操作不匹配", "AUTH_TAC_SCENE_MISMATCH");
|
||||
}
|
||||
// 短信接口把 validToken 定义为必填,因此 required=false 不能在客户端被解释成
|
||||
// “跳过验证”。后端必须为短信场景启用 TAC,或另行签发可消费的免验证票据。
|
||||
if (value.required !== true) {
|
||||
throw contractError(
|
||||
"服务端未要求行为验证,无法取得发送短信所需票据",
|
||||
"AUTH_TAC_POLICY_INCOMPLETE",
|
||||
);
|
||||
}
|
||||
const providerCode = requireText(value.providerCode, "行为验证服务商").toUpperCase();
|
||||
if (providerCode !== "TIANAI") {
|
||||
throw contractError("当前仅支持 TIANAI 行为验证服务", "AUTH_TAC_PROVIDER_UNSUPPORTED");
|
||||
}
|
||||
const captchaType = requireText(value.captchaType, "行为验证码类型").toUpperCase();
|
||||
if (!SUPPORTED_TAC_TYPES.has(captchaType)) {
|
||||
throw contractError("服务端返回了客户端不支持的验证码类型", "AUTH_TAC_TYPE_UNSUPPORTED");
|
||||
}
|
||||
const ttlSeconds = Number(value.ttlSeconds);
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1) {
|
||||
throw contractError("行为验证策略缺少有效期", "AUTH_TAC_TTL_INVALID");
|
||||
}
|
||||
return { required: true, providerCode, captchaType, sceneCode, ttlSeconds };
|
||||
};
|
||||
|
||||
export const createTacRenderContext = ({
|
||||
requestId,
|
||||
baseUrl,
|
||||
clientId,
|
||||
tenantId,
|
||||
sceneCode,
|
||||
subject,
|
||||
requirement,
|
||||
}) => {
|
||||
const normalizedRequirement = normalizeCaptchaRequirement(requirement, sceneCode);
|
||||
const normalizedBaseUrl = requireText(baseUrl, "后端地址").replace(/\/+$/, "");
|
||||
if (!/^https:\/\/[^/]+/i.test(normalizedBaseUrl)) {
|
||||
throw contractError("行为验证只允许使用 HTTPS 后端地址", "AUTH_TAC_HTTPS_REQUIRED");
|
||||
}
|
||||
const normalizedSubject = requireText(subject, "手机号");
|
||||
if (!isAuthPhone(normalizedSubject)) {
|
||||
throw contractError("请输入正确的手机号", "AUTH_PHONE_INVALID");
|
||||
}
|
||||
return {
|
||||
requestId: requireText(requestId, "验证请求标识"),
|
||||
baseUrl: normalizedBaseUrl,
|
||||
challengeUrl: `${normalizedBaseUrl}/captcha/challenge`,
|
||||
verifyUrl: `${normalizedBaseUrl}/captcha/verify`,
|
||||
clientId: requireText(clientId, "客户端标识"),
|
||||
tenantId: requireText(tenantId, "租户标识"),
|
||||
sceneCode: normalizedRequirement.sceneCode,
|
||||
subject: normalizedSubject,
|
||||
providerCode: normalizedRequirement.providerCode,
|
||||
captchaType: normalizedRequirement.captchaType,
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeTacSuccess = (value, expectedRequestId) => {
|
||||
if (!value || typeof value !== "object" || value.requestId !== expectedRequestId) {
|
||||
throw contractError("行为验证结果已过期或与当前请求不匹配", "AUTH_TAC_RESULT_STALE");
|
||||
}
|
||||
const validToken = requireText(value.validToken, "行为验证票据");
|
||||
const expireSeconds = Number(value.expireSeconds);
|
||||
if (!Number.isInteger(expireSeconds) || expireSeconds < 1) {
|
||||
throw contractError("行为验证票据缺少有效期", "AUTH_TAC_RESULT_INVALID");
|
||||
}
|
||||
return { requestId: value.requestId, validToken, expireSeconds };
|
||||
};
|
||||
+7
-1
@@ -1,9 +1,15 @@
|
||||
export const runtimeConfig = {
|
||||
mode: 'mock',
|
||||
baseUrl: 'http://182.61.18.23:8080',
|
||||
baseUrl: 'https://backend-api.ddxcjp.cn',
|
||||
clientId: '428a8310cd442757ae699df5d894f051',
|
||||
tenantId: '000000'
|
||||
}
|
||||
|
||||
export const isMockMode = () => runtimeConfig.mode === 'mock'
|
||||
export const hasRemoteConfig = () => runtimeConfig.mode === 'remote' && Boolean(runtimeConfig.baseUrl && runtimeConfig.clientId)
|
||||
|
||||
export const resolveRuntimeMode = () => {
|
||||
if (isMockMode()) return 'mock'
|
||||
if (hasRemoteConfig()) return 'remote'
|
||||
throw new Error('运行模式配置无效:只允许 mock 或配置完整的 remote')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 放弃表单确认的唯一生命周期所有者。页面只负责把 visible 同步到自己的 ref;
|
||||
// 重复返回复用同一 Promise,确认、取消或卸载都会先清空内部状态再释放等待者。
|
||||
export const createDiscardConfirmation = (setVisible) => {
|
||||
if (typeof setVisible !== "function") {
|
||||
throw new TypeError("createDiscardConfirmation 的 setVisible 必须是函数");
|
||||
}
|
||||
|
||||
let pendingPromise = null;
|
||||
let resolvePending = null;
|
||||
|
||||
const settle = (confirmed) => {
|
||||
const resolve = resolvePending;
|
||||
resolvePending = null;
|
||||
pendingPromise = null;
|
||||
setVisible(false);
|
||||
resolve?.(confirmed === true);
|
||||
};
|
||||
|
||||
const request = () => {
|
||||
if (pendingPromise) return pendingPromise;
|
||||
setVisible(true);
|
||||
pendingPromise = new Promise((resolve) => {
|
||||
resolvePending = resolve;
|
||||
});
|
||||
return pendingPromise;
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
request,
|
||||
confirm: () => settle(true),
|
||||
cancel: () => settle(false),
|
||||
dispose: () => settle(false),
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,89 @@
|
||||
const CURRENT_GENEALOGY_KEY = 'jiapu_current_genealogy_id'
|
||||
const CURRENT_GENEALOGY_INVALIDATED_KEY = 'jiapu_current_genealogy_invalidated'
|
||||
|
||||
const normalizeGenealogyId = (genealogyId) => {
|
||||
if (typeof genealogyId !== 'string') {
|
||||
throw new TypeError('当前家谱 ID 必须是词法字符串')
|
||||
}
|
||||
if (!genealogyId || genealogyId.trim() !== genealogyId || /[\u0000-\u001f\u007f]/.test(genealogyId)) {
|
||||
throw new TypeError('当前家谱 ID 必须是无边界空白的非空字符串')
|
||||
}
|
||||
return genealogyId
|
||||
}
|
||||
|
||||
const markCurrentGenealogyInvalidated = () => {
|
||||
uni.removeStorageSync(CURRENT_GENEALOGY_KEY)
|
||||
uni.setStorageSync(CURRENT_GENEALOGY_INVALIDATED_KEY, '1')
|
||||
}
|
||||
|
||||
const readCurrentGenealogyInvalidated = () => {
|
||||
const stored = uni.getStorageSync(CURRENT_GENEALOGY_INVALIDATED_KEY)
|
||||
if (stored === '' || stored === null || stored === undefined) return false
|
||||
if (stored !== '1') uni.setStorageSync(CURRENT_GENEALOGY_INVALIDATED_KEY, '1')
|
||||
return true
|
||||
}
|
||||
|
||||
const readCurrentGenealogyId = () => {
|
||||
const stored = uni.getStorageSync(CURRENT_GENEALOGY_KEY)
|
||||
if (stored === '' || stored === null || stored === undefined) return ''
|
||||
try {
|
||||
return normalizeGenealogyId(stored)
|
||||
} catch {
|
||||
// 旧版本或损坏的存储值不应继续流入路由和权限判断。发现异常时只清理
|
||||
// 这一唯一键;下一次取得可用家谱列表后再按确定顺序建立新上下文。
|
||||
markCurrentGenealogyInvalidated()
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const genealogyContext = {
|
||||
getCurrentGenealogyId: () => uni.getStorageSync(CURRENT_GENEALOGY_KEY) || '',
|
||||
setCurrentGenealogyId: (genealogyId) => uni.setStorageSync(CURRENT_GENEALOGY_KEY, String(genealogyId)),
|
||||
clearCurrentGenealogyId: () => uni.removeStorageSync(CURRENT_GENEALOGY_KEY)
|
||||
getCurrentGenealogyId: readCurrentGenealogyId,
|
||||
setCurrentGenealogyId: (genealogyId) => {
|
||||
const normalizedId = normalizeGenealogyId(genealogyId)
|
||||
uni.setStorageSync(CURRENT_GENEALOGY_KEY, normalizedId)
|
||||
uni.removeStorageSync(CURRENT_GENEALOGY_INVALIDATED_KEY)
|
||||
return normalizedId
|
||||
},
|
||||
invalidateCurrentGenealogyId: () => {
|
||||
markCurrentGenealogyInvalidated()
|
||||
return ''
|
||||
},
|
||||
isCurrentGenealogyInvalidated: readCurrentGenealogyInvalidated,
|
||||
clearCurrentGenealogyId: () => {
|
||||
uni.removeStorageSync(CURRENT_GENEALOGY_KEY)
|
||||
uni.removeStorageSync(CURRENT_GENEALOGY_INVALIDATED_KEY)
|
||||
},
|
||||
reconcileCurrentGenealogyId: (availableIds, preferredId = '') => {
|
||||
if (!Array.isArray(availableIds)) {
|
||||
throw new TypeError('可用家谱 ID 必须是数组')
|
||||
}
|
||||
const normalizedIds = availableIds.map(normalizeGenealogyId)
|
||||
if (new Set(normalizedIds).size !== normalizedIds.length) {
|
||||
throw new TypeError('可用家谱列表包含重复 ID')
|
||||
}
|
||||
const normalizedPreferredId = preferredId === ''
|
||||
? ''
|
||||
: normalizeGenealogyId(preferredId)
|
||||
const storedId = readCurrentGenealogyId()
|
||||
if (normalizedPreferredId) {
|
||||
if (!normalizedIds.includes(normalizedPreferredId)) {
|
||||
markCurrentGenealogyInvalidated()
|
||||
return ''
|
||||
}
|
||||
return genealogyContext.setCurrentGenealogyId(normalizedPreferredId)
|
||||
}
|
||||
if (storedId) {
|
||||
if (!normalizedIds.includes(storedId)) {
|
||||
markCurrentGenealogyInvalidated()
|
||||
return ''
|
||||
}
|
||||
return genealogyContext.setCurrentGenealogyId(storedId)
|
||||
}
|
||||
if (readCurrentGenealogyInvalidated()) return ''
|
||||
if (!normalizedIds.length) {
|
||||
uni.removeStorageSync(CURRENT_GENEALOGY_KEY)
|
||||
return ''
|
||||
}
|
||||
return genealogyContext.setCurrentGenealogyId(normalizedIds[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// 当前两个产品选项同时决定 OpenAPI GenealogyCreate/UpdateBody 的
|
||||
// visibility 与 joinMode,因此它们是“访问预设”,不是单一可见范围。
|
||||
export const GENEALOGY_ACCESS_PRESET = Object.freeze({
|
||||
MEMBER_ONLY: "MEMBER_ONLY",
|
||||
PUBLIC_APPLY: "PUBLIC_APPLY",
|
||||
});
|
||||
|
||||
export const GENEALOGY_ACCESS_PRESET_OPTIONS = Object.freeze([
|
||||
Object.freeze({
|
||||
value: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
|
||||
label: "仅成员可见",
|
||||
}),
|
||||
Object.freeze({
|
||||
value: GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY,
|
||||
label: "公开可申请",
|
||||
}),
|
||||
]);
|
||||
|
||||
export const isGenealogyAccessPreset = (preset) =>
|
||||
Object.values(GENEALOGY_ACCESS_PRESET).includes(preset);
|
||||
|
||||
// 当前导出只在字段说明中给出 0/1/2 示例,并未提供正式 enum。这里锁住页面
|
||||
// 当前能表达的两个组合,未知组合一律返回 null;邀请码模式必须等后端合同补齐,
|
||||
// 不能把 PUBLIC_APPLY 冒充 joinMode=2。
|
||||
const apiAccessByPreset = Object.freeze({
|
||||
[GENEALOGY_ACCESS_PRESET.MEMBER_ONLY]: Object.freeze({
|
||||
visibility: "2",
|
||||
joinMode: "0",
|
||||
}),
|
||||
[GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY]: Object.freeze({
|
||||
visibility: "1",
|
||||
joinMode: "1",
|
||||
}),
|
||||
});
|
||||
|
||||
export const getGenealogyAccessPresetLabel = (preset) =>
|
||||
GENEALOGY_ACCESS_PRESET_OPTIONS.find((item) => item.value === preset)
|
||||
?.label || "访问规则待确认";
|
||||
|
||||
export const toApiGenealogyAccess = (preset) => {
|
||||
const access = apiAccessByPreset[preset];
|
||||
return access ? { ...access } : null;
|
||||
};
|
||||
|
||||
export const fromApiGenealogyAccess = ({ visibility, joinMode } = {}) => {
|
||||
const match = Object.entries(apiAccessByPreset).find(
|
||||
([, access]) =>
|
||||
access.visibility === visibility && access.joinMode === joinMode,
|
||||
);
|
||||
return match?.[0] || null;
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
// generationNo 来自 int64。当前页面必须做加法与排序,因此只接受 JavaScript
|
||||
// 可以精确表达的正安全整数;超出范围时失败关闭,不能把两个世代舍入成同一值。
|
||||
export const MAX_GENERATION_NO = Number.MAX_SAFE_INTEGER
|
||||
|
||||
// 三个限制逐一对应 GenerationPoemBatchBody:输入总长 26000、单代文字
|
||||
// 最多 50 字符、单批最多 500 个世代,不能再合并成一个含义模糊的“长度”。
|
||||
export const MAX_GENERATION_COUNT = 500
|
||||
export const MAX_GENERATION_TEXT_LENGTH = 50
|
||||
export const MAX_GENERATION_POEM_INPUT_LENGTH = 26000
|
||||
export const GENERATION_POEM_STATUS = Object.freeze({
|
||||
ACTIVE: '0',
|
||||
DISABLED: '1',
|
||||
})
|
||||
|
||||
const normalizePositiveInteger = (value) => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : null
|
||||
}
|
||||
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) return null
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) ? number : null
|
||||
}
|
||||
|
||||
// 路由查询始终先经过这里:缺省值可以回落,显式传入的非法值必须失败关闭,
|
||||
// 不能用 Number(...) || fallback 把 0、小数或科学计数法悄悄改成另一代。
|
||||
export const normalizeGenerationNumber = (value, fallback) => {
|
||||
const candidate = value === undefined || value === null || value === ''
|
||||
? fallback
|
||||
: value
|
||||
const number = normalizePositiveInteger(candidate)
|
||||
return number !== null && number <= MAX_GENERATION_NO ? number : null
|
||||
}
|
||||
|
||||
const separatorPattern = /[\s,,;;、/|]/u
|
||||
const separatorRunPattern = /[\s,,;;、/|]+/u
|
||||
const forbiddenControlPattern = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u
|
||||
const codePointLength = (value) => Array.from(value).length
|
||||
|
||||
const normalizeGenerationText = (value) => {
|
||||
const text = String(value ?? '').trim()
|
||||
if (
|
||||
!text ||
|
||||
codePointLength(text) > MAX_GENERATION_TEXT_LENGTH ||
|
||||
forbiddenControlPattern.test(text)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// 按 OpenAPI 的批量输入规则解析:没有分隔符时每个 Unicode code point 对应
|
||||
// 一代;存在空格、逗号、分号、顿号、斜杠或竖线时,每个分段可包含多字。
|
||||
export const validateGenerationPoemText = (value) => {
|
||||
const input = String(value ?? '')
|
||||
if (!input.trim()) {
|
||||
return Object.freeze({ valid: false, generations: [], message: '请录入字辈内容' })
|
||||
}
|
||||
if (codePointLength(input) > MAX_GENERATION_POEM_INPUT_LENGTH) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations: [],
|
||||
message: `字辈输入最多 ${MAX_GENERATION_POEM_INPUT_LENGTH} 个字符`,
|
||||
})
|
||||
}
|
||||
if (forbiddenControlPattern.test(input)) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations: [],
|
||||
message: '字辈内容包含不支持的控制字符',
|
||||
})
|
||||
}
|
||||
|
||||
const generations = separatorPattern.test(input)
|
||||
? input.split(separatorRunPattern).map((item) => item.trim()).filter(Boolean)
|
||||
: Array.from(input)
|
||||
if (!generations.length) {
|
||||
return Object.freeze({ valid: false, generations, message: '请录入字辈内容' })
|
||||
}
|
||||
if (generations.length > MAX_GENERATION_COUNT) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations,
|
||||
message: `一次最多录入 ${MAX_GENERATION_COUNT} 个世代`,
|
||||
})
|
||||
}
|
||||
if (generations.some((item) => normalizeGenerationText(item) === null)) {
|
||||
return Object.freeze({
|
||||
valid: false,
|
||||
generations,
|
||||
message: `每代字辈文字不能为空且最多 ${MAX_GENERATION_TEXT_LENGTH} 个字符`,
|
||||
})
|
||||
}
|
||||
return Object.freeze({ valid: true, generations, message: '' })
|
||||
}
|
||||
|
||||
const normalizeExistingRow = (row) => {
|
||||
const generationNo = normalizeGenerationNumber(row?.generationNo)
|
||||
const generationText = normalizeGenerationText(row?.generationText)
|
||||
if (
|
||||
generationNo === null ||
|
||||
generationText === null ||
|
||||
!Object.values(GENERATION_POEM_STATUS).includes(row?.status)
|
||||
) {
|
||||
throw new TypeError('已有字辈记录不符合 GenerationPoemView 合同')
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
generationNo,
|
||||
generationText,
|
||||
status: row.status,
|
||||
current: false,
|
||||
}
|
||||
}
|
||||
|
||||
// 只检查已建立字辈的起点到本批起始代之间,避免把家谱尚未录入的更早世代
|
||||
// 当成缺口;算法只遍历已有记录,能够安全处理几十代、几百代甚至稀疏数据。
|
||||
export const findFirstGenerationGap = (
|
||||
rows,
|
||||
startGeneration,
|
||||
sequenceStartGeneration,
|
||||
) => {
|
||||
const start = normalizeGenerationNumber(startGeneration)
|
||||
if (start === null) return null
|
||||
const generations = [...new Set(
|
||||
(Array.isArray(rows) ? rows : [])
|
||||
.filter((row) => row?.status !== GENERATION_POEM_STATUS.DISABLED)
|
||||
.map((row) => normalizeGenerationNumber(row?.generationNo))
|
||||
.filter((generationNo) => generationNo !== null && generationNo < start),
|
||||
)].sort((left, right) => left - right)
|
||||
if (!generations.length) return null
|
||||
const explicitSequenceStart = sequenceStartGeneration === undefined
|
||||
? null
|
||||
: normalizeGenerationNumber(sequenceStartGeneration)
|
||||
if (sequenceStartGeneration !== undefined && explicitSequenceStart === null) {
|
||||
throw new TypeError('字辈序列起点无效')
|
||||
}
|
||||
let expected = explicitSequenceStart ?? generations[0]
|
||||
for (const generationNo of generations) {
|
||||
if (generationNo > expected) return expected
|
||||
expected = generationNo + 1
|
||||
}
|
||||
return expected < start ? expected : null
|
||||
}
|
||||
|
||||
// 批量维护只替换本次覆盖区间。disableMissing=true 时,后续遗漏记录会被
|
||||
// 标成停用但仍保留,确保历史数据不会因一次编辑被物理删除。
|
||||
export const mergeGenerationPoemRows = ({
|
||||
existingRows,
|
||||
generationTexts,
|
||||
startGeneration,
|
||||
currentGeneration,
|
||||
disableMissing,
|
||||
}) => {
|
||||
const start = normalizeGenerationNumber(startGeneration)
|
||||
const current = normalizeGenerationNumber(currentGeneration)
|
||||
if (typeof disableMissing !== 'boolean') {
|
||||
throw new TypeError('disableMissing 必须是布尔值')
|
||||
}
|
||||
if (!Array.isArray(generationTexts) || !generationTexts.length) {
|
||||
throw new TypeError('字辈合并参数无效')
|
||||
}
|
||||
if (generationTexts.length > MAX_GENERATION_COUNT) {
|
||||
throw new RangeError(`一次最多 ${MAX_GENERATION_COUNT} 个世代`)
|
||||
}
|
||||
const nextGenerationTexts = generationTexts.map(normalizeGenerationText)
|
||||
if (nextGenerationTexts.some((item) => item === null)) {
|
||||
throw new TypeError('单代字辈文字无效')
|
||||
}
|
||||
if (start === null || current === null) throw new TypeError('字辈合并参数无效')
|
||||
if (start > MAX_GENERATION_NO - nextGenerationTexts.length + 1) {
|
||||
throw new RangeError('字辈世代超出支持范围')
|
||||
}
|
||||
const end = start + nextGenerationTexts.length - 1
|
||||
|
||||
const rowsByGeneration = new Map()
|
||||
for (const item of Array.isArray(existingRows) ? existingRows : []) {
|
||||
const row = normalizeExistingRow(item)
|
||||
if (rowsByGeneration.has(row.generationNo)) {
|
||||
throw new TypeError('已有字辈包含重复的世代记录')
|
||||
}
|
||||
rowsByGeneration.set(row.generationNo, row)
|
||||
}
|
||||
|
||||
for (let index = 0; index < nextGenerationTexts.length; index += 1) {
|
||||
const generationNo = start + index
|
||||
const existing = rowsByGeneration.get(generationNo)
|
||||
rowsByGeneration.set(generationNo, {
|
||||
...existing,
|
||||
generationNo,
|
||||
generationText: nextGenerationTexts[index],
|
||||
status: GENERATION_POEM_STATUS.ACTIVE,
|
||||
current: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (disableMissing) {
|
||||
for (const [generationNo, row] of rowsByGeneration) {
|
||||
if (generationNo > end) {
|
||||
rowsByGeneration.set(generationNo, {
|
||||
...row,
|
||||
status: GENERATION_POEM_STATUS.DISABLED,
|
||||
current: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...rowsByGeneration.values()]
|
||||
.sort((left, right) => left.generationNo - right.generationNo)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
current:
|
||||
row.status === GENERATION_POEM_STATUS.ACTIVE &&
|
||||
row.generationNo === current,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// 本文件是 52 个活动页面导航语义的唯一运行时所有者。页面只能使用路由键,
|
||||
// 不得自行复制路径、父页、允许来源、参数或流程结果规则。
|
||||
const defineRoute = (route) =>
|
||||
Object.freeze({
|
||||
...route,
|
||||
parentParamMap: Object.freeze(route.parentParamMap || {}),
|
||||
requiredParams: Object.freeze(route.requiredParams || []),
|
||||
optionalParams: Object.freeze(route.optionalParams || []),
|
||||
allowedSources: Object.freeze(route.allowedSources || []),
|
||||
resultOperations: Object.freeze(route.resultOperations || []),
|
||||
});
|
||||
|
||||
const defineNoticeTarget = (routeKey, params) =>
|
||||
Object.freeze({ routeKey, params: Object.freeze(params) });
|
||||
|
||||
export const NOTICE_TARGETS = Object.freeze({
|
||||
GENEALOGY_REVIEW: defineNoticeTarget("G10", ["genealogyId"]),
|
||||
GENEALOGY_HOME: defineNoticeTarget("G01", ["genealogyId"]),
|
||||
});
|
||||
|
||||
export const ROUTES = Object.freeze({
|
||||
A01: defineRoute({
|
||||
path: "/pages/auth/a01-entry",
|
||||
kind: "auth-root",
|
||||
parent: null,
|
||||
resultOperations: ["password-reset"],
|
||||
}),
|
||||
A04: defineRoute({
|
||||
path: "/pages/auth/a04-register",
|
||||
kind: "page",
|
||||
parent: "A01",
|
||||
allowedSources: ["A01"],
|
||||
}),
|
||||
A05: defineRoute({
|
||||
path: "/pages/auth/a05-reset-password",
|
||||
kind: "page",
|
||||
parent: "A01",
|
||||
allowedSources: ["A01"],
|
||||
}),
|
||||
G01: defineRoute({
|
||||
path: "/pages/genealogy/g01-my-genealogies",
|
||||
kind: "root",
|
||||
parent: null,
|
||||
optionalParams: ["genealogyId"],
|
||||
}),
|
||||
G03: defineRoute({
|
||||
path: "/pages/genealogy/g03-create-genealogy",
|
||||
kind: "flow",
|
||||
parent: "G01",
|
||||
allowedSources: ["G01"],
|
||||
}),
|
||||
G05: defineRoute({
|
||||
path: "/pages/genealogy/g05-genealogy-overview",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "G03", "G06", "G09"],
|
||||
}),
|
||||
G06: defineRoute({
|
||||
path: "/pages/genealogy/g06-search-genealogies",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
optionalParams: ["mode"],
|
||||
allowedSources: ["G01", "G03", "G09"],
|
||||
}),
|
||||
G08: defineRoute({
|
||||
path: "/pages/genealogy/g08-join-application",
|
||||
kind: "flow",
|
||||
parent: "G06",
|
||||
requiredParams: ["genealogyId"],
|
||||
optionalParams: ["source"],
|
||||
allowedSources: ["G01", "G05", "G06", "G09"],
|
||||
}),
|
||||
G09: defineRoute({
|
||||
path: "/pages/genealogy/g09-my-applications",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
optionalParams: ["status"],
|
||||
allowedSources: ["G01", "G05", "G06", "G08"],
|
||||
}),
|
||||
G10: defineRoute({
|
||||
path: "/pages/genealogy/g10-application-review",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "G05", "N01", "N02"],
|
||||
}),
|
||||
G11: defineRoute({
|
||||
path: "/pages/genealogy/g11-genealogy-settings",
|
||||
kind: "flow",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G05"],
|
||||
}),
|
||||
G12: defineRoute({
|
||||
path: "/pages/genealogy/g12-generation-poems",
|
||||
kind: "flow",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "G05"],
|
||||
}),
|
||||
T01: defineRoute({
|
||||
path: "/pages/tree/t01-tree-overview",
|
||||
kind: "page",
|
||||
parent: "G05",
|
||||
requiredParams: ["genealogyId"],
|
||||
optionalParams: ["selectedId"],
|
||||
allowedSources: ["G01", "G05", "T04", "T06", "T07"],
|
||||
}),
|
||||
T03: defineRoute({
|
||||
path: "/pages/tree/t03-member-profile",
|
||||
kind: "single",
|
||||
parent: "T01",
|
||||
parentParamMap: { selectedId: "personId" },
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["T01", "T07", "R02"],
|
||||
resultOperations: ["member-open-requested"],
|
||||
}),
|
||||
T04: defineRoute({
|
||||
path: "/pages/tree/t04-add-relative",
|
||||
kind: "flow",
|
||||
parent: "T01",
|
||||
parentParamMap: { selectedId: "personId" },
|
||||
requiredParams: ["genealogyId"],
|
||||
optionalParams: ["personId", "mode"],
|
||||
allowedSources: ["T01"],
|
||||
}),
|
||||
T05: defineRoute({
|
||||
path: "/pages/tree/t05-edit-member",
|
||||
kind: "flow",
|
||||
parent: "T03",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["T03"],
|
||||
}),
|
||||
T06: defineRoute({
|
||||
path: "/pages/tree/t06-edit-relationship",
|
||||
kind: "flow",
|
||||
parent: "T01",
|
||||
parentParamMap: { selectedId: "personId" },
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["T01"],
|
||||
}),
|
||||
T07: defineRoute({
|
||||
path: "/pages/tree/t07-member-directory",
|
||||
kind: "page",
|
||||
parent: "T01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["T01"],
|
||||
}),
|
||||
T08: defineRoute({
|
||||
path: "/pages/tree/t08-member-states",
|
||||
kind: "page",
|
||||
parent: "T03",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["T03"],
|
||||
}),
|
||||
F01: defineRoute({
|
||||
path: "/pages/family/f01-family-feed",
|
||||
kind: "root",
|
||||
parent: null,
|
||||
optionalParams: ["genealogyId"],
|
||||
}),
|
||||
F02: defineRoute({
|
||||
path: "/pages/family/f02-publish-feed",
|
||||
kind: "flow",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
F03: defineRoute({
|
||||
path: "/pages/family/f03-feed-detail",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId", "feedId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
F04: defineRoute({
|
||||
path: "/pages/family/f04-article-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
F05: defineRoute({
|
||||
path: "/pages/family/f05-article-detail",
|
||||
kind: "page",
|
||||
parent: "F04",
|
||||
requiredParams: ["genealogyId", "articleId"],
|
||||
allowedSources: ["F04"],
|
||||
}),
|
||||
F06: defineRoute({
|
||||
path: "/pages/family/f06-article-editor",
|
||||
kind: "flow",
|
||||
parent: "F04",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
optionalParams: ["articleId"],
|
||||
allowedSources: ["F04", "F05"],
|
||||
}),
|
||||
F07: defineRoute({
|
||||
path: "/pages/family/f07-album-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
F08: defineRoute({
|
||||
path: "/pages/family/f08-album-detail",
|
||||
kind: "page",
|
||||
parent: "F07",
|
||||
requiredParams: ["genealogyId", "albumId"],
|
||||
allowedSources: ["F07"],
|
||||
}),
|
||||
F09: defineRoute({
|
||||
path: "/pages/family/f09-media-upload",
|
||||
kind: "flow",
|
||||
parent: "F08",
|
||||
requiredParams: ["genealogyId", "albumId"],
|
||||
allowedSources: ["F08"],
|
||||
}),
|
||||
F10: defineRoute({
|
||||
path: "/pages/family/f10-video-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R01: defineRoute({
|
||||
path: "/pages/records/r01-people-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R02: defineRoute({
|
||||
path: "/pages/records/r02-person-detail",
|
||||
kind: "flow",
|
||||
parent: "R01",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
optionalParams: ["personId"],
|
||||
allowedSources: ["R01"],
|
||||
}),
|
||||
R03: defineRoute({
|
||||
path: "/pages/records/r03-gift-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R04: defineRoute({
|
||||
path: "/pages/records/r04-gift-editor",
|
||||
kind: "flow",
|
||||
parent: "R03",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
optionalParams: ["relativeId"],
|
||||
allowedSources: ["R03"],
|
||||
}),
|
||||
R05: defineRoute({
|
||||
path: "/pages/records/r05-ritual-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R06: defineRoute({
|
||||
path: "/pages/records/r06-ritual-detail",
|
||||
kind: "page",
|
||||
parent: "R05",
|
||||
requiredParams: ["genealogyId", "ceremonyId"],
|
||||
allowedSources: ["R05"],
|
||||
}),
|
||||
R07: defineRoute({
|
||||
path: "/pages/records/r07-ritual-editor",
|
||||
kind: "flow",
|
||||
parent: "R05",
|
||||
requiredParams: ["genealogyId", "mode"],
|
||||
optionalParams: ["ceremonyId"],
|
||||
allowedSources: ["R05", "R06"],
|
||||
}),
|
||||
R08: defineRoute({
|
||||
path: "/pages/records/r08-growth-journal",
|
||||
kind: "page",
|
||||
parent: "R02",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["R02", "T03"],
|
||||
}),
|
||||
R09: defineRoute({
|
||||
path: "/pages/records/r09-life-events",
|
||||
kind: "page",
|
||||
parent: "R02",
|
||||
requiredParams: ["genealogyId", "personId"],
|
||||
allowedSources: ["R02", "T03"],
|
||||
}),
|
||||
R10: defineRoute({
|
||||
path: "/pages/records/r10-memo-list",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
R11: defineRoute({
|
||||
path: "/pages/records/r11-merit-records",
|
||||
kind: "page",
|
||||
parent: "F01",
|
||||
requiredParams: ["genealogyId"],
|
||||
allowedSources: ["F01"],
|
||||
}),
|
||||
N01: defineRoute({
|
||||
path: "/pages/notification/n01-message-center",
|
||||
kind: "page",
|
||||
parent: "G01",
|
||||
optionalParams: ["genealogyId"],
|
||||
allowedSources: ["G01", "M01"],
|
||||
}),
|
||||
N02: defineRoute({
|
||||
path: "/pages/notification/n02-message-detail",
|
||||
kind: "page",
|
||||
parent: "N01",
|
||||
requiredParams: ["id"],
|
||||
allowedSources: ["N01"],
|
||||
}),
|
||||
M01: defineRoute({
|
||||
path: "/pages/profile/m01-profile-home",
|
||||
kind: "root",
|
||||
parent: null,
|
||||
}),
|
||||
M02: defineRoute({
|
||||
path: "/pages/profile/m02-edit-profile",
|
||||
kind: "flow",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M03: defineRoute({
|
||||
path: "/pages/profile/m03-security-settings",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M04: defineRoute({
|
||||
path: "/pages/profile/m04-change-password",
|
||||
kind: "flow",
|
||||
parent: "M03",
|
||||
allowedSources: ["M03"],
|
||||
}),
|
||||
M05: defineRoute({
|
||||
path: "/pages/profile/m05-change-phone",
|
||||
kind: "flow",
|
||||
parent: "M03",
|
||||
allowedSources: ["M03"],
|
||||
}),
|
||||
M06: defineRoute({
|
||||
path: "/pages/profile/m06-help-center",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M07: defineRoute({
|
||||
path: "/pages/profile/m07-feedback",
|
||||
kind: "flow",
|
||||
parent: "M06",
|
||||
allowedSources: ["M06"],
|
||||
}),
|
||||
M08: defineRoute({
|
||||
path: "/pages/profile/m08-promotion",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M09: defineRoute({
|
||||
path: "/pages/profile/m09-vip-orders",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
M10: defineRoute({
|
||||
path: "/pages/profile/m10-about-settings",
|
||||
kind: "page",
|
||||
parent: "M01",
|
||||
allowedSources: ["M01"],
|
||||
}),
|
||||
});
|
||||
|
||||
export const ROOT_ROUTE_KEYS = Object.freeze(["A01", "G01", "F01", "M01"]);
|
||||
|
||||
export const getRoute = (routeKey) =>
|
||||
typeof routeKey === "string" &&
|
||||
Object.prototype.hasOwnProperty.call(ROUTES, routeKey)
|
||||
? ROUTES[routeKey]
|
||||
: null;
|
||||
|
||||
export const getRouteKeyByPath = (path) => {
|
||||
if (typeof path !== "string") return null;
|
||||
const normalizedPath = `/${path.replace(/^\/+/, "")}`;
|
||||
return (
|
||||
Object.keys(ROUTES).find(
|
||||
(routeKey) => ROUTES[routeKey].path === normalizedPath,
|
||||
) || null
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,800 @@
|
||||
import {
|
||||
NOTICE_TARGETS,
|
||||
ROOT_ROUTE_KEYS,
|
||||
getRoute,
|
||||
getRouteKeyByPath,
|
||||
} from "./navigation-routes.js";
|
||||
|
||||
const navigationResults = new Map();
|
||||
const pageInstanceTokens = new WeakMap();
|
||||
let navigationInFlight = null;
|
||||
let pageInstanceSequence = 0;
|
||||
|
||||
const hasOwn = (value, key) =>
|
||||
Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
const getPageInstanceToken = (page) => {
|
||||
if ((typeof page !== "object" || page === null) && typeof page !== "function") {
|
||||
return null;
|
||||
}
|
||||
let token = pageInstanceTokens.get(page);
|
||||
if (!token) {
|
||||
pageInstanceSequence += 1;
|
||||
token = pageInstanceSequence;
|
||||
pageInstanceTokens.set(page, token);
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
const snapshotDataRecord = (name, value) => {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new TypeError(`${name} 必须是对象`);
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError(`${name} 必须是普通对象`);
|
||||
}
|
||||
const snapshot = Object.create(null);
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
for (const key of Reflect.ownKeys(descriptors)) {
|
||||
if (typeof key !== "string") {
|
||||
throw new TypeError(`${name} 不接受 Symbol 字段`);
|
||||
}
|
||||
const descriptor = descriptors[key];
|
||||
if (!descriptor.enumerable) {
|
||||
throw new TypeError(`${name} 字段 ${key} 必须可枚举`);
|
||||
}
|
||||
if (!hasOwn(descriptor, "value")) {
|
||||
throw new TypeError(`${name} 字段 ${key} 不得使用访问器`);
|
||||
}
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: descriptor.value,
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
}
|
||||
return Object.freeze(snapshot);
|
||||
};
|
||||
|
||||
const assertScalarString = (name, value) => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new TypeError(`导航参数 ${name} 必须是非空字符串`);
|
||||
}
|
||||
};
|
||||
|
||||
const validateRouteParams = (routeKey, params, requireAll) => {
|
||||
const route = getRoute(routeKey);
|
||||
if (!route) throw new Error(`未知路由键:${routeKey}`);
|
||||
const normalizedParams = snapshotDataRecord(`${routeKey} 导航参数`, params);
|
||||
|
||||
const allowed = new Set([...route.requiredParams, ...route.optionalParams]);
|
||||
for (const name of Object.keys(normalizedParams)) {
|
||||
if (!allowed.has(name)) {
|
||||
throw new Error(`${routeKey} 不接受导航参数 ${name}`);
|
||||
}
|
||||
assertScalarString(name, normalizedParams[name]);
|
||||
}
|
||||
|
||||
if (requireAll) {
|
||||
for (const name of route.requiredParams) {
|
||||
if (!hasOwn(normalizedParams, name)) {
|
||||
throw new Error(`${routeKey} 缺少导航参数 ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { route, params: normalizedParams };
|
||||
};
|
||||
|
||||
const encodeQuery = (entries) =>
|
||||
entries
|
||||
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
|
||||
const buildValidatedRouteUrl = (routeKey, route, params, sourceKey = "") => {
|
||||
if (sourceKey !== "") {
|
||||
assertScalarString("sourceKey", sourceKey);
|
||||
if (!route.allowedSources.includes(sourceKey)) {
|
||||
throw new Error(`${sourceKey} 不能进入 ${routeKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...route.requiredParams, ...route.optionalParams]
|
||||
.filter((name) => hasOwn(params, name))
|
||||
.map((name) => [name, params[name]]);
|
||||
if (sourceKey !== "") entries.push(["sourceKey", sourceKey]);
|
||||
const query = encodeQuery(entries);
|
||||
return query ? `${route.path}?${query}` : route.path;
|
||||
};
|
||||
|
||||
export const buildRouteUrl = (routeKey, params = {}, sourceKey = "") => {
|
||||
const validated = validateRouteParams(routeKey, params, true);
|
||||
return buildValidatedRouteUrl(
|
||||
routeKey,
|
||||
validated.route,
|
||||
validated.params,
|
||||
sourceKey,
|
||||
);
|
||||
};
|
||||
|
||||
const getPagePath = (page) => {
|
||||
const rawPath = page?.route || page?.$page?.route || page?.$page?.fullPath;
|
||||
if (typeof rawPath !== "string" || rawPath.length === 0) return "";
|
||||
return rawPath.split("?")[0];
|
||||
};
|
||||
|
||||
const getPageParams = (page, route) => {
|
||||
const params = Object.create(null);
|
||||
const routeFields = [...route.requiredParams, ...route.optionalParams];
|
||||
const candidates = [page?.$page?.query, page?.$page?.options, page?.options];
|
||||
|
||||
// UniApp 各端暴露页面参数的位置不同。只投影注册表声明字段,避免把
|
||||
// sourceKey 等传输字段误当成目标业务参数;后出现的容器拥有更高优先级,
|
||||
// 但空的顶层 options 不会遮蔽 $page.query/$page.options。
|
||||
for (const candidate of candidates) {
|
||||
if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
||||
continue;
|
||||
}
|
||||
for (const name of routeFields) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(candidate, name);
|
||||
if (!descriptor) continue;
|
||||
if (!descriptor.enumerable) {
|
||||
throw new TypeError(`页面导航参数 ${name} 必须可枚举`);
|
||||
}
|
||||
if (!hasOwn(descriptor, "value")) {
|
||||
throw new TypeError(`页面导航参数 ${name} 不得使用访问器`);
|
||||
}
|
||||
params[name] = descriptor.value;
|
||||
}
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const readPageStack = () => {
|
||||
if (typeof getCurrentPages !== "function") {
|
||||
throw new Error("当前运行环境不支持页面栈读取");
|
||||
}
|
||||
const stack = getCurrentPages();
|
||||
if (!Array.isArray(stack)) throw new Error("页面栈格式无效");
|
||||
return stack;
|
||||
};
|
||||
|
||||
const getPageRouteKey = (page) => getRouteKeyByPath(getPagePath(page));
|
||||
|
||||
const assertActualSource = (routeKey, sourceKey) => {
|
||||
const route = getRoute(routeKey);
|
||||
if (!route) throw new Error(`未知路由键:${routeKey}`);
|
||||
assertScalarString("sourceKey", sourceKey);
|
||||
if (!route.allowedSources.includes(sourceKey)) {
|
||||
throw new Error(`${sourceKey} 不能进入 ${routeKey}`);
|
||||
}
|
||||
|
||||
const stack = readPageStack();
|
||||
const actualSource = stack.length > 0
|
||||
? getPageRouteKey(stack[stack.length - 1])
|
||||
: null;
|
||||
if (actualSource !== sourceKey) {
|
||||
throw new Error(
|
||||
`当前真实页面 ${actualSource || "UNKNOWN"} 与声明来源 ${sourceKey} 不一致`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isCurrentTarget = (routeKey, params) => {
|
||||
const stack = readPageStack();
|
||||
if (stack.length === 0) return false;
|
||||
const currentPage = stack[stack.length - 1];
|
||||
if (getPageRouteKey(currentPage) !== routeKey) return false;
|
||||
|
||||
const route = getRoute(routeKey);
|
||||
const currentParams = getPageParams(currentPage, route);
|
||||
for (const name of [...route.requiredParams, ...route.optionalParams]) {
|
||||
const targetHasParam = hasOwn(params, name);
|
||||
const currentHasParam = hasOwn(currentParams, name);
|
||||
if (targetHasParam !== currentHasParam) return false;
|
||||
if (targetHasParam && currentParams[name] !== params[name]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const runUniNavigation = (
|
||||
key,
|
||||
invoke,
|
||||
onStart = null,
|
||||
onAbort = null,
|
||||
onSuccess = null,
|
||||
) => {
|
||||
if (navigationInFlight?.key === key) return navigationInFlight.promise;
|
||||
if (navigationInFlight) return Promise.resolve(false);
|
||||
|
||||
let resolvePromise;
|
||||
let rejectPromise;
|
||||
let settled = false;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
resolvePromise = resolve;
|
||||
rejectPromise = reject;
|
||||
});
|
||||
const flight = { key, promise };
|
||||
navigationInFlight = flight;
|
||||
|
||||
const releaseFlight = () => {
|
||||
if (navigationInFlight === flight) navigationInFlight = null;
|
||||
};
|
||||
const resolveFlight = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
releaseFlight();
|
||||
resolvePromise(true);
|
||||
};
|
||||
const rejectFlight = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
let rejection = error;
|
||||
try {
|
||||
onAbort?.();
|
||||
} catch (abortError) {
|
||||
rejection = abortError;
|
||||
}
|
||||
releaseFlight();
|
||||
rejectPromise(rejection);
|
||||
};
|
||||
|
||||
try {
|
||||
onStart?.();
|
||||
invoke({
|
||||
success: () => {
|
||||
try {
|
||||
onSuccess?.();
|
||||
resolveFlight();
|
||||
} catch (error) {
|
||||
rejectFlight(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => rejectFlight(
|
||||
new Error(error?.errMsg || "页面跳转失败"),
|
||||
),
|
||||
complete: () => {
|
||||
if (!settled) {
|
||||
rejectFlight(new Error("页面跳转未返回 success 或 fail"));
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
rejectFlight(error);
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
const asNavigationPromise = (execute) => {
|
||||
try {
|
||||
return execute();
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateNavigationResult = (routeKey, result) => {
|
||||
const route = getRoute(routeKey);
|
||||
if (!route) throw new Error(`未知路由键:${routeKey}`);
|
||||
const normalizedResult = snapshotDataRecord(`${routeKey} 导航结果`, result);
|
||||
|
||||
const fields = Reflect.ownKeys(normalizedResult);
|
||||
const allowedFields = new Set(["operation", "entityId", "refresh"]);
|
||||
for (const field of fields) {
|
||||
if (typeof field !== "string" || !allowedFields.has(field)) {
|
||||
throw new Error(`${routeKey} 导航结果不接受字段 ${String(field)}`);
|
||||
}
|
||||
}
|
||||
for (const requiredField of ["operation", "refresh"]) {
|
||||
if (!hasOwn(normalizedResult, requiredField)) {
|
||||
throw new Error(`${routeKey} 导航结果缺少 ${requiredField}`);
|
||||
}
|
||||
}
|
||||
|
||||
assertScalarString("operation", normalizedResult.operation);
|
||||
if (!route.resultOperations.includes(normalizedResult.operation)) {
|
||||
throw new Error(`${routeKey} 不接受结果 operation=${normalizedResult.operation}`);
|
||||
}
|
||||
if (hasOwn(normalizedResult, "entityId")) {
|
||||
assertScalarString("entityId", normalizedResult.entityId);
|
||||
}
|
||||
if (typeof normalizedResult.refresh !== "boolean") {
|
||||
throw new TypeError("导航结果 refresh 必须是布尔值");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
operation: normalizedResult.operation,
|
||||
...(hasOwn(normalizedResult, "entityId")
|
||||
? { entityId: normalizedResult.entityId }
|
||||
: {}),
|
||||
refresh: normalizedResult.refresh,
|
||||
});
|
||||
};
|
||||
|
||||
const runWithNavigationResult = (
|
||||
routeKey,
|
||||
targetPage,
|
||||
sourcePage,
|
||||
targetParams,
|
||||
result,
|
||||
key,
|
||||
invoke,
|
||||
) => {
|
||||
const envelope = Object.seal({
|
||||
targetPageToken: getPageInstanceToken(targetPage),
|
||||
sourcePageToken: getPageInstanceToken(sourcePage),
|
||||
targetParams,
|
||||
result,
|
||||
});
|
||||
let started = false;
|
||||
|
||||
const rollback = () => {
|
||||
if (navigationResults.get(routeKey) !== envelope) return;
|
||||
navigationResults.delete(routeKey);
|
||||
};
|
||||
|
||||
const bindCreatedTarget = () => {
|
||||
if (navigationResults.get(routeKey) !== envelope) return;
|
||||
const stack = readPageStack();
|
||||
const currentPage = stack.length > 0 ? stack[stack.length - 1] : null;
|
||||
if (getPageRouteKey(currentPage) !== routeKey) {
|
||||
throw new Error(`${routeKey} 导航成功后未找到规范目标页`);
|
||||
}
|
||||
const currentPageToken = getPageInstanceToken(currentPage);
|
||||
const route = getRoute(routeKey);
|
||||
const currentParams = validateRouteParams(
|
||||
routeKey,
|
||||
getPageParams(currentPage, route),
|
||||
true,
|
||||
).params;
|
||||
const paramsMatch = routeParamsEqual(
|
||||
route,
|
||||
currentParams,
|
||||
envelope.targetParams,
|
||||
);
|
||||
if (envelope.targetPageToken !== null) {
|
||||
if (currentPageToken !== envelope.targetPageToken || !paramsMatch) {
|
||||
throw new Error(`${routeKey} 导航成功后的目标实例或参数不匹配`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (currentPageToken === envelope.sourcePageToken || !paramsMatch) {
|
||||
throw new Error(`${routeKey} 导航成功后的目标实例或参数不匹配`);
|
||||
}
|
||||
envelope.targetPageToken = currentPageToken;
|
||||
};
|
||||
|
||||
const promise = runUniNavigation(
|
||||
key,
|
||||
invoke,
|
||||
() => {
|
||||
started = true;
|
||||
// 任意新完成流程取得全局转场锁后,所有旧结果都已经错过各自目标页的
|
||||
// onShow 生命周期,必须先整体淘汰再写入当前结果;当前转场失败时只删除
|
||||
// 当前 envelope,不复活任何路由的陈旧结果。
|
||||
navigationResults.clear();
|
||||
navigationResults.set(routeKey, envelope);
|
||||
},
|
||||
rollback,
|
||||
bindCreatedTarget,
|
||||
);
|
||||
if (!started) return promise;
|
||||
|
||||
// 清理副作用挂在原 Promise 上,但仍返回原 Promise,保证相同语义调用的
|
||||
// Promise 身份一致;失败处理分支吞掉派生链结果,不改变调用方收到的拒绝。
|
||||
promise.then(
|
||||
(completed) => {
|
||||
if (!completed) rollback();
|
||||
},
|
||||
() => rollback(),
|
||||
);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const pushPage = (url) =>
|
||||
runUniNavigation(`push:${url}`, (callbacks) =>
|
||||
uni.navigateTo({ url, ...callbacks }));
|
||||
|
||||
const activateExistingSinglePage = (routeKey, params, url) => {
|
||||
const stack = readPageStack();
|
||||
const targetIndexes = [];
|
||||
for (let index = stack.length - 2; index >= 0; index -= 1) {
|
||||
if (getPageRouteKey(stack[index]) === routeKey) {
|
||||
targetIndexes.push(index);
|
||||
}
|
||||
}
|
||||
if (targetIndexes.length > 1) {
|
||||
const error = new Error("T03_STACK_CONFLICT:页面栈中存在多个成员页实例");
|
||||
error.code = "T03_STACK_CONFLICT";
|
||||
throw error;
|
||||
}
|
||||
const targetIndex = targetIndexes[0] ?? -1;
|
||||
if (targetIndex < 0) return pushPage(url);
|
||||
|
||||
// T03 是当前唯一 single 页面。复用实例前必须锁定家谱上下文;若跨家谱强行
|
||||
// 复用,成员轨迹和一次性结果都会串到错误领域,因此明确失败而不是猜测回退。
|
||||
const existingParams = validateRouteParams(
|
||||
routeKey,
|
||||
getPageParams(stack[targetIndex], getRoute(routeKey)),
|
||||
true,
|
||||
).params;
|
||||
if (existingParams.genealogyId !== params.genealogyId) {
|
||||
const error = new Error("T03_CONTEXT_CONFLICT:既有成员页属于其他家谱");
|
||||
error.code = "T03_CONTEXT_CONFLICT";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = validateNavigationResult(routeKey, {
|
||||
operation: "member-open-requested",
|
||||
entityId: params.personId,
|
||||
refresh: false,
|
||||
});
|
||||
const delta = stack.length - 1 - targetIndex;
|
||||
const transitionKey = `single:${routeKey}:${delta}:${params.personId}`;
|
||||
return runWithNavigationResult(
|
||||
routeKey,
|
||||
stack[targetIndex],
|
||||
stack[stack.length - 1],
|
||||
existingParams,
|
||||
result,
|
||||
transitionKey,
|
||||
(callbacks) => uni.navigateBack({ delta, ...callbacks }),
|
||||
);
|
||||
};
|
||||
|
||||
export const openPage = (routeKey, params = {}, sourceKey = "") =>
|
||||
asNavigationPromise(() => {
|
||||
const validated = validateRouteParams(routeKey, params, true);
|
||||
const url = buildValidatedRouteUrl(
|
||||
routeKey,
|
||||
validated.route,
|
||||
validated.params,
|
||||
sourceKey,
|
||||
);
|
||||
assertActualSource(routeKey, sourceKey);
|
||||
if (isCurrentTarget(routeKey, validated.params)) return Promise.resolve(false);
|
||||
if (validated.route.kind === "single") {
|
||||
return activateExistingSinglePage(routeKey, validated.params, url);
|
||||
}
|
||||
return pushPage(url);
|
||||
});
|
||||
|
||||
export const goRoot = (routeKey, params = {}) =>
|
||||
asNavigationPromise(() => {
|
||||
if (!ROOT_ROUTE_KEYS.includes(routeKey)) {
|
||||
throw new Error(`${routeKey} 不是根语义`);
|
||||
}
|
||||
const validated = validateRouteParams(routeKey, params, true);
|
||||
const url = buildValidatedRouteUrl(
|
||||
routeKey,
|
||||
validated.route,
|
||||
validated.params,
|
||||
);
|
||||
if (isCurrentTarget(routeKey, validated.params)) return Promise.resolve(false);
|
||||
return runUniNavigation(`root:${url}`, (callbacks) =>
|
||||
uni.reLaunch({ url, ...callbacks }));
|
||||
});
|
||||
|
||||
export const openNoticeTarget = (targetType, params, sourceKey = "N02") =>
|
||||
asNavigationPromise(() => {
|
||||
assertScalarString("通知目标类型", targetType);
|
||||
assertScalarString("通知来源", sourceKey);
|
||||
if (!hasOwn(NOTICE_TARGETS, targetType)) {
|
||||
throw new Error(`未知通知目标:${targetType}`);
|
||||
}
|
||||
if (!["N01", "N02"].includes(sourceKey)) {
|
||||
throw new Error(`${sourceKey} 不能进入通知目标`);
|
||||
}
|
||||
|
||||
const target = NOTICE_TARGETS[targetType];
|
||||
const normalizedParams = snapshotDataRecord("通知目标参数", params);
|
||||
const expectedParams = new Set(target.params);
|
||||
for (const name of Object.keys(normalizedParams)) {
|
||||
if (!expectedParams.has(name)) {
|
||||
throw new Error(`${targetType} 通知目标不接受参数 ${name}`);
|
||||
}
|
||||
assertScalarString(name, normalizedParams[name]);
|
||||
}
|
||||
for (const name of target.params) {
|
||||
if (!hasOwn(normalizedParams, name)) {
|
||||
throw new Error(`${targetType} 通知目标缺少参数 ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return target.routeKey === "G01"
|
||||
? goRoot(target.routeKey, normalizedParams)
|
||||
: openPage(target.routeKey, normalizedParams, sourceKey);
|
||||
});
|
||||
|
||||
const collectParentParams = (childRoute, childParams, parentRoute) => {
|
||||
const parentParams = {};
|
||||
const parentFields = [
|
||||
...parentRoute.requiredParams,
|
||||
...parentRoute.optionalParams,
|
||||
];
|
||||
for (const name of parentFields) {
|
||||
if (
|
||||
hasOwn(childParams, name) &&
|
||||
typeof childParams[name] === "string" &&
|
||||
childParams[name].length > 0
|
||||
) {
|
||||
parentParams[name] = childParams[name];
|
||||
}
|
||||
}
|
||||
for (const [parentParam, childParam] of Object.entries(childRoute.parentParamMap)) {
|
||||
if (
|
||||
hasOwn(childParams, childParam) &&
|
||||
typeof childParams[childParam] === "string" &&
|
||||
childParams[childParam].length > 0
|
||||
) {
|
||||
parentParams[parentParam] = childParams[childParam];
|
||||
}
|
||||
}
|
||||
return parentParams;
|
||||
};
|
||||
|
||||
const runParentFallback = (routeKey, params) => {
|
||||
let childRoute = getRoute(routeKey);
|
||||
let childParams = params;
|
||||
|
||||
// 深链没有可信历史。逐级只继承注册表声明的同名字段和 parentParamMap;任何
|
||||
// 必填字段不足的中间页都不能伪造,继续上溯到首个可合法构造的父语义。
|
||||
while (childRoute?.parent) {
|
||||
const parentKey = childRoute.parent;
|
||||
const parentRoute = getRoute(parentKey);
|
||||
const parentParams = collectParentParams(childRoute, childParams, parentRoute);
|
||||
const hasRequiredParams = parentRoute.requiredParams.every((name) =>
|
||||
hasOwn(parentParams, name));
|
||||
|
||||
if (hasRequiredParams) {
|
||||
const url = buildRouteUrl(parentKey, parentParams);
|
||||
if (ROOT_ROUTE_KEYS.includes(parentKey)) {
|
||||
return runUniNavigation(`fallback-root:${url}`, (callbacks) =>
|
||||
uni.reLaunch({ url, ...callbacks }));
|
||||
}
|
||||
return runUniNavigation(`fallback-replace:${url}`, (callbacks) =>
|
||||
uni.redirectTo({ url, ...callbacks }));
|
||||
}
|
||||
|
||||
childRoute = parentRoute;
|
||||
childParams = parentParams;
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
};
|
||||
|
||||
export const goBack = () =>
|
||||
asNavigationPromise(() => {
|
||||
const stack = readPageStack();
|
||||
if (stack.length === 0) throw new Error("当前页面栈为空");
|
||||
if (stack.length > 1) {
|
||||
return runUniNavigation("back:1", (callbacks) =>
|
||||
uni.navigateBack({ delta: 1, ...callbacks }));
|
||||
}
|
||||
|
||||
const currentPage = stack[0];
|
||||
const currentRouteKey = getPageRouteKey(currentPage);
|
||||
if (!currentRouteKey) throw new Error("当前页面不在活动路由注册表中");
|
||||
if (ROOT_ROUTE_KEYS.includes(currentRouteKey)) return Promise.resolve(false);
|
||||
const currentRoute = getRoute(currentRouteKey);
|
||||
return runParentFallback(
|
||||
currentRouteKey,
|
||||
getPageParams(currentPage, currentRoute),
|
||||
);
|
||||
});
|
||||
|
||||
const findNearestPageIndex = (stack, routeKey) => {
|
||||
for (let index = stack.length - 2; index >= 0; index -= 1) {
|
||||
if (getPageRouteKey(stack[index]) === routeKey) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const getResultTransitionKey = (result) => {
|
||||
if (!result) return "";
|
||||
const entityKey = hasOwn(result, "entityId")
|
||||
? `1:${encodeURIComponent(result.entityId)}`
|
||||
: "0";
|
||||
return `:${encodeURIComponent(result.operation)}:${entityKey}:${result.refresh ? "1" : "0"}`;
|
||||
};
|
||||
|
||||
const routeParamsEqual = (route, left, right) => {
|
||||
for (const name of [...route.requiredParams, ...route.optionalParams]) {
|
||||
const leftHasParam = hasOwn(left, name);
|
||||
const rightHasParam = hasOwn(right, name);
|
||||
if (leftHasParam !== rightHasParam) return false;
|
||||
if (leftHasParam && left[name] !== right[name]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const assertResultSourceContext = (targetRoute, targetParams, sourcePage) => {
|
||||
const sourceRouteKey = getPageRouteKey(sourcePage);
|
||||
const sourceRoute = getRoute(sourceRouteKey);
|
||||
if (!sourceRoute) return;
|
||||
|
||||
const sourceParams = validateRouteParams(
|
||||
sourceRouteKey,
|
||||
getPageParams(sourcePage, sourceRoute),
|
||||
true,
|
||||
).params;
|
||||
const targetFields = new Set([
|
||||
...targetRoute.requiredParams,
|
||||
...targetRoute.optionalParams,
|
||||
]);
|
||||
for (const name of [...sourceRoute.requiredParams, ...sourceRoute.optionalParams]) {
|
||||
if (
|
||||
targetFields.has(name) &&
|
||||
hasOwn(sourceParams, name) &&
|
||||
hasOwn(targetParams, name) &&
|
||||
sourceParams[name] !== targetParams[name]
|
||||
) {
|
||||
throw new Error(`完成来源上下文 ${name} 与目标不一致`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const returnToValidated = (routeKey, targetParams, result) => {
|
||||
const validatedTarget = validateRouteParams(
|
||||
routeKey,
|
||||
targetParams,
|
||||
result !== null,
|
||||
);
|
||||
const route = validatedTarget.route;
|
||||
const normalizedTargetParams = validatedTarget.params;
|
||||
const stack = readPageStack();
|
||||
const targetIndex = findNearestPageIndex(stack, routeKey);
|
||||
const sourcePage = stack.length > 0 ? stack[stack.length - 1] : null;
|
||||
let targetPage = null;
|
||||
let targetIdentity;
|
||||
let transitionKey;
|
||||
let invoke;
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
targetPage = stack[targetIndex];
|
||||
const existingParams = getPageParams(stack[targetIndex], route);
|
||||
const validatedExisting = validateRouteParams(routeKey, existingParams, true);
|
||||
targetIdentity = validatedExisting.params;
|
||||
for (const [name, value] of Object.entries(normalizedTargetParams)) {
|
||||
if (
|
||||
!hasOwn(validatedExisting.params, name) ||
|
||||
validatedExisting.params[name] !== value
|
||||
) {
|
||||
throw new Error(`${routeKey} 显式目标参数 ${name} 与最近实例不一致`);
|
||||
}
|
||||
}
|
||||
const delta = stack.length - 1 - targetIndex;
|
||||
transitionKey = `return:${routeKey}:${delta}${getResultTransitionKey(result)}`;
|
||||
invoke = (callbacks) => uni.navigateBack({ delta, ...callbacks });
|
||||
} else {
|
||||
const completeTarget = validateRouteParams(
|
||||
routeKey,
|
||||
normalizedTargetParams,
|
||||
true,
|
||||
);
|
||||
targetIdentity = completeTarget.params;
|
||||
const url = buildValidatedRouteUrl(routeKey, route, completeTarget.params);
|
||||
const resultKey = getResultTransitionKey(result);
|
||||
if (ROOT_ROUTE_KEYS.includes(routeKey)) {
|
||||
transitionKey = `return-root:${url}${resultKey}`;
|
||||
invoke = (callbacks) => uni.reLaunch({ url, ...callbacks });
|
||||
} else {
|
||||
transitionKey = `return-replace:${url}${resultKey}`;
|
||||
invoke = (callbacks) => uni.redirectTo({ url, ...callbacks });
|
||||
}
|
||||
}
|
||||
|
||||
if (result) {
|
||||
assertResultSourceContext(route, targetIdentity, sourcePage);
|
||||
}
|
||||
|
||||
return result
|
||||
? runWithNavigationResult(
|
||||
routeKey,
|
||||
targetPage,
|
||||
sourcePage,
|
||||
targetIdentity,
|
||||
result,
|
||||
transitionKey,
|
||||
invoke,
|
||||
)
|
||||
: runUniNavigation(transitionKey, invoke);
|
||||
};
|
||||
|
||||
export const returnTo = (routeKey, targetParams = {}, ...unsupportedArgs) =>
|
||||
asNavigationPromise(() => {
|
||||
if (unsupportedArgs.length > 0) {
|
||||
throw new Error("returnTo 不接受流程结果,请使用 finishPage");
|
||||
}
|
||||
return returnToValidated(routeKey, targetParams, null);
|
||||
});
|
||||
|
||||
export const finishPage = (routeKey, targetParams = {}, result) =>
|
||||
asNavigationPromise(() => {
|
||||
const normalizedResult = validateNavigationResult(routeKey, result);
|
||||
return returnToValidated(routeKey, targetParams, normalizedResult);
|
||||
});
|
||||
|
||||
export const consumeNavigationResult = (routeKey) => {
|
||||
const route = getRoute(routeKey);
|
||||
if (!route) throw new Error(`未知路由键:${routeKey}`);
|
||||
const stack = readPageStack();
|
||||
if (
|
||||
stack.length === 0 ||
|
||||
getPageRouteKey(stack[stack.length - 1]) !== routeKey
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!navigationResults.has(routeKey)) return null;
|
||||
const envelope = navigationResults.get(routeKey);
|
||||
const currentPage = stack[stack.length - 1];
|
||||
const currentPageToken = getPageInstanceToken(currentPage);
|
||||
if (envelope.targetPageToken !== null) {
|
||||
if (currentPageToken !== envelope.targetPageToken) return null;
|
||||
} else if (currentPageToken === envelope.sourcePageToken) {
|
||||
return null;
|
||||
}
|
||||
const currentParams = validateRouteParams(
|
||||
routeKey,
|
||||
getPageParams(currentPage, route),
|
||||
true,
|
||||
).params;
|
||||
if (!routeParamsEqual(route, currentParams, envelope.targetParams)) return null;
|
||||
navigationResults.delete(routeKey);
|
||||
return envelope.result;
|
||||
};
|
||||
|
||||
const resolveBackActionFromContext = ({
|
||||
transientOpen = false,
|
||||
internalTrail = false,
|
||||
dirty = false,
|
||||
submitting = false,
|
||||
}) => {
|
||||
if (transientOpen) return "close-transient";
|
||||
if (internalTrail) return "pop-internal-trail";
|
||||
if (submitting) return "block-submitting";
|
||||
if (dirty) return "confirm-discard";
|
||||
return "go-back";
|
||||
};
|
||||
|
||||
const validateBackContext = (context) => {
|
||||
const normalizedContext = snapshotDataRecord("返回守卫上下文", context);
|
||||
for (const flag of ["transientOpen", "internalTrail", "dirty", "submitting"]) {
|
||||
if (hasOwn(normalizedContext, flag) && typeof normalizedContext[flag] !== "boolean") {
|
||||
throw new TypeError(`返回守卫状态 ${flag} 必须是布尔值`);
|
||||
}
|
||||
}
|
||||
return normalizedContext;
|
||||
};
|
||||
|
||||
// onBackPress 必须同步返回布尔值;而网关自己的 navigateBack 会再次触发该钩子。
|
||||
// 这里统一放行网关回调来源,并把页面异步守卫从同步平台钩子中安全分离。
|
||||
export const handleBackPress = (event, requestBack) => {
|
||||
if (event?.from === "navigateBack") return false;
|
||||
if (typeof requestBack !== "function") {
|
||||
throw new TypeError("handleBackPress 的 requestBack 必须是函数");
|
||||
}
|
||||
void Promise.resolve()
|
||||
.then(requestBack)
|
||||
.catch((error) => console.error("页面返回守卫执行失败", error));
|
||||
return true;
|
||||
};
|
||||
|
||||
export const resolveBackAction = (context = {}) =>
|
||||
resolveBackActionFromContext(validateBackContext(context));
|
||||
|
||||
export const runBackGuard = async (context = {}) => {
|
||||
const normalizedContext = validateBackContext(context);
|
||||
const action = resolveBackActionFromContext(normalizedContext);
|
||||
if (action === "go-back") return goBack();
|
||||
|
||||
const callback = normalizedContext[action];
|
||||
if (typeof callback !== "function") {
|
||||
throw new TypeError(`返回守卫缺少 ${action} 回调`);
|
||||
}
|
||||
const outcome = await callback();
|
||||
if (action !== "confirm-discard") {
|
||||
return true;
|
||||
}
|
||||
if (outcome !== true) return false;
|
||||
return goBack();
|
||||
};
|
||||
+38
-4
@@ -1,7 +1,41 @@
|
||||
import { genealogyContext } from "./genealogy-context.js";
|
||||
|
||||
const TOKEN_KEY = 'jiapu_token'
|
||||
|
||||
export const session = {
|
||||
getToken: () => uni.getStorageSync(TOKEN_KEY) || '',
|
||||
saveToken: (token) => uni.setStorageSync(TOKEN_KEY, token),
|
||||
clear: () => uni.removeStorageSync(TOKEN_KEY)
|
||||
const normalizeToken = (token) => {
|
||||
if (typeof token !== 'string' || !token || token.trim() !== token) {
|
||||
throw new TypeError('会话令牌必须是无边界空白的非空字符串')
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
const readToken = () => {
|
||||
const stored = uni.getStorageSync(TOKEN_KEY)
|
||||
if (stored === '' || stored === null || stored === undefined) return ''
|
||||
try {
|
||||
return normalizeToken(stored)
|
||||
} catch {
|
||||
// 损坏令牌与其账号作用域下的当前家谱必须一起失效,避免页面继续展示
|
||||
// 上一个账号的领域上下文。
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
genealogyContext.clearCurrentGenealogyId()
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const session = {
|
||||
getToken: readToken,
|
||||
saveToken: (token) => {
|
||||
const normalizedToken = normalizeToken(token)
|
||||
const previousToken = readToken()
|
||||
if (previousToken !== normalizedToken) {
|
||||
genealogyContext.clearCurrentGenealogyId()
|
||||
}
|
||||
uni.setStorageSync(TOKEN_KEY, normalizedToken)
|
||||
return normalizedToken
|
||||
},
|
||||
clear: () => {
|
||||
uni.removeStorageSync(TOKEN_KEY)
|
||||
genealogyContext.clearCurrentGenealogyId()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user