完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+345 -65
View File
@@ -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 }
}
}
+114
View File
@@ -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
View File
@@ -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')
}
+34
View File
@@ -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),
});
};
+85 -3
View File
@@ -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])
}
}
+51
View File
@@ -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;
};
+216
View File
@@ -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,
}))
}
+398
View File
@@ -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
);
};
+800
View File
@@ -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
View File
@@ -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()
}
}