feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
-3561
View File
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
import { isWriteOutcomeUnknown } from "@/utils/request-outcome.js";
export const AUTH_VERIFICATION_OPERATION = Object.freeze({
PASSWORD_LOGIN: "password-login",
SMS_LOGIN: "sms-login",
@@ -121,22 +123,4 @@ export const normalizeTacSuccess = (value, expectedRequestId) => {
return { requestId: value.requestId, validToken, expireSeconds };
};
export const isSmsDeliveryOutcomeUnknown = (error) => {
if (!error || typeof error !== "object") return false;
if (
error.code === "REQUEST_TIMEOUT" ||
error.code === "NETWORK_ERROR" ||
error.code === "RESPONSE_INVALID" ||
error.code === "REQUEST_CANCELLED"
) {
return true;
}
if (error.code === "HTTP_ERROR" || error.code === "BUSINESS_ERROR") {
const status = Number(
error.code === "HTTP_ERROR" ? error.httpStatus : error.businessCode,
);
if (!Number.isInteger(status)) return true;
return !(status >= 400 && status < 500 && status !== 408);
}
return false;
};
export const isSmsDeliveryOutcomeUnknown = isWriteOutcomeUnknown;
+2 -2
View File
@@ -1,8 +1,8 @@
export const DEFAULT_MALE_AVATAR =
"/static/assets/foundation/transparent/mjpc0703_A_cartoon_illustration_of_a_boy_wearing_a_red_Chinese__0@2x.png";
"/static/assets/foundation/transparent/default-avatar-male.png";
export const DEFAULT_FEMALE_AVATAR =
"/static/assets/foundation/transparent/loyel003_Ancient_Beauty_Wearing_Tang_Dynasty_ClothingLooking_to_07cb98a9-ec37-4620-a032-ebe511fa93b5@2x.png";
"/static/assets/foundation/transparent/default-avatar-female.png";
export const getDefaultAvatar = (sex) =>
String(sex ?? "").trim() === "1"
-15
View File
@@ -1,15 +0,0 @@
export const runtimeConfig = {
mode: 'remote',
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')
}
+7
View File
@@ -0,0 +1,7 @@
const minuteTimestamp = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?/;
export const formatMinuteTimestamp = (value) => {
const text = String(value || "").trim();
const match = text.match(minuteTimestamp);
return match ? `${match[1]} ${match[2]}` : text;
};
@@ -5,6 +5,18 @@ export const GENEALOGY_ACCESS_PRESET = Object.freeze({
PUBLIC_APPLY: "PUBLIC_APPLY",
});
export const GENEALOGY_VISIBILITY = Object.freeze({
PRIVATE: "0",
PUBLIC: "1",
MEMBER_ONLY: "2",
});
export const GENEALOGY_JOIN_MODE = Object.freeze({
CLOSED: "0",
REVIEW: "1",
INVITATION: "2",
});
export const GENEALOGY_ACCESS_PRESET_OPTIONS = Object.freeze([
Object.freeze({
value: GENEALOGY_ACCESS_PRESET.MEMBER_ONLY,
@@ -19,17 +31,22 @@ export const GENEALOGY_ACCESS_PRESET_OPTIONS = Object.freeze([
export const isGenealogyAccessPreset = (preset) =>
Object.values(GENEALOGY_ACCESS_PRESET).includes(preset);
// 当前导出只在字段说明中给出 0/1/2 示例,并未提供正式 enum。这里锁住页面
// 当前能表达的两个组合,未知组合一律返回 null;邀请码模式必须等后端合同补齐,
// 不能把 PUBLIC_APPLY 冒充 joinMode=2。
export const isGenealogyVisibility = (value) =>
Object.values(GENEALOGY_VISIBILITY).includes(value);
export const isGenealogyJoinMode = (value) =>
Object.values(GENEALOGY_JOIN_MODE).includes(value);
// OpenAPI 为两个字段分别声明了 0/1/2,但当前产品只提供以下两个完整预设。
// 其他合法组合不能被悄悄改写成相近选项,页面应明确显示“访问规则待确认”。
const apiAccessByPreset = Object.freeze({
[GENEALOGY_ACCESS_PRESET.MEMBER_ONLY]: Object.freeze({
visibility: "2",
joinMode: "0",
visibility: GENEALOGY_VISIBILITY.MEMBER_ONLY,
joinMode: GENEALOGY_JOIN_MODE.CLOSED,
}),
[GENEALOGY_ACCESS_PRESET.PUBLIC_APPLY]: Object.freeze({
visibility: "1",
joinMode: "1",
visibility: GENEALOGY_VISIBILITY.PUBLIC,
joinMode: GENEALOGY_JOIN_MODE.REVIEW,
}),
});
+71
View File
@@ -0,0 +1,71 @@
import { GENERATION_POEM_STATUS } from '@/services/api/generation-poem-contract.js'
export { GENERATION_POEM_STATUS }
// 三个限制逐一对应 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
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: '' })
}
-216
View File
@@ -1,216 +0,0 @@
// 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,
}))
}
@@ -1,4 +1,4 @@
import { appApi } from "@/utils/api.js";
import { fileUploadApi } from "@/services/api/file-upload-service.js";
import { calcMD5Bytes } from "@/utils/md5.js";
const createUploadError = (message, code) => {
@@ -50,9 +50,9 @@ const pickBrowserImage = () => new Promise((resolve, reject) => {
}
const reader = new globalThis.FileReader();
reader.onload = () => {
const data = reader.result;
const fileData = reader.result;
cleanup();
if (!(data instanceof ArrayBuffer)) {
if (!(fileData instanceof ArrayBuffer)) {
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
return;
}
@@ -61,7 +61,7 @@ const pickBrowserImage = () => new Promise((resolve, reject) => {
fileName: browserFile.name || "image",
size: browserFile.size,
contentType: browserFile.type || "image/*",
data,
data: fileData,
});
};
reader.onerror = () => {
@@ -109,9 +109,9 @@ const pickBrowserVideo = () => new Promise((resolve, reject) => {
}
const reader = new globalThis.FileReader();
reader.onload = () => {
const data = reader.result;
const fileData = reader.result;
cleanup();
if (!(data instanceof ArrayBuffer)) {
if (!(fileData instanceof ArrayBuffer)) {
reject(createUploadError("读取视频数据失败", "VIDEO_READ_INVALID"));
return;
}
@@ -120,7 +120,7 @@ const pickBrowserVideo = () => new Promise((resolve, reject) => {
fileName: browserFile.name || "video",
size: browserFile.size,
contentType: browserFile.type || "video/*",
data,
data: fileData,
});
};
reader.onerror = () => {
@@ -165,8 +165,8 @@ const readNativeFile = (filePath) => new Promise((resolve, reject) => {
(file) => {
const reader = new globalThis.plus.io.FileReader();
reader.onloadend = (event) => {
const data = event?.target?.result ?? reader.result;
if (!(data instanceof ArrayBuffer)) {
const fileData = event?.target?.result ?? reader.result;
if (!(fileData instanceof ArrayBuffer)) {
reject(createUploadError("读取图片数据失败", "IMAGE_READ_INVALID"));
return;
}
@@ -175,7 +175,7 @@ const readNativeFile = (filePath) => new Promise((resolve, reject) => {
fileName: file.name || "image",
size: file.size,
contentType: file.type || "image/*",
data,
data: fileData,
});
};
reader.onerror = () => reject(createUploadError("读取图片数据失败", "IMAGE_READ_FAILED"));
@@ -187,20 +187,6 @@ const readNativeFile = (filePath) => new Promise((resolve, reject) => {
);
});
export const toConsumerOssId = (ossId) => {
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
}
const numericId = Number(ossId);
if (!Number.isSafeInteger(numericId)) {
throw createUploadError(
"文件已上传,但服务返回的文件 ID 超出 APP 安全整数范围;需由后端统一文件 ID 合同后才能关联业务数据",
"OSS_ID_UNSAFE",
);
}
return numericId;
};
const normalizeUploadReceiptOssId = (ossId) => {
if (typeof ossId !== "string" || !/^[1-9]\d*$/.test(ossId)) {
throw createUploadError("文件服务返回的文件 ID 无效", "OSS_ID_INVALID");
@@ -233,7 +219,7 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
chunkSize: file.size,
contentType: file.contentType,
};
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
const initialized = await fileUploadApi.initializeResumableUpload(initPayload, { requestController });
if (initialized.instant) return toUploadReceipt(initialized);
const chunkPayload = {
uploadId: initialized.uploadId,
@@ -241,11 +227,11 @@ export const pickAndUploadImage = async ({ requestController = null } = {}) => {
chunkMd5: fileMd5,
};
if (file.browserFile) {
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
await fileUploadApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
} else {
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
await fileUploadApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
}
const completed = await appApi.completeResumableUpload({
const completed = await fileUploadApi.completeResumableUpload({
uploadId: initialized.uploadId,
fileName: file.fileName,
fileMd5,
@@ -276,7 +262,7 @@ export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
chunkSize: file.size,
contentType: file.contentType || "video/*",
};
const initialized = await appApi.initializeResumableUpload(initPayload, { requestController });
const initialized = await fileUploadApi.initializeResumableUpload(initPayload, { requestController });
if (initialized.instant) return toUploadReceipt(initialized);
const chunkPayload = {
uploadId: initialized.uploadId,
@@ -284,11 +270,11 @@ export const pickAndUploadVideo = async ({ requestController = null } = {}) => {
chunkMd5: fileMd5,
};
if (file.browserFile) {
await appApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
await fileUploadApi.uploadBrowserResumableChunk(chunkPayload, file.browserFile, { requestController });
} else {
await appApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
await fileUploadApi.uploadResumableChunk({ ...chunkPayload, filePath: file.filePath }, { requestController });
}
const completed = await appApi.completeResumableUpload({
const completed = await fileUploadApi.completeResumableUpload({
uploadId: initialized.uploadId,
fileName,
fileMd5,
@@ -3,7 +3,7 @@ import {
ROOT_ROUTE_KEYS,
getRoute,
getRouteKeyByPath,
} from "./navigation-routes.js";
} from "./routes.js";
const navigationResults = new Map();
const pageInstanceTokens = new WeakMap();
@@ -26,35 +26,11 @@ const getPageInstanceToken = (page) => {
return token;
};
const snapshotDataRecord = (name, value) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
const copyDataRecord = (name, value) => {
if (!value || 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);
return Object.freeze({ ...value });
};
const assertScalarString = (name, value) => {
@@ -66,7 +42,7 @@ const assertScalarString = (name, value) => {
const validateRouteParams = (routeKey, params, requireAll) => {
const route = getRoute(routeKey);
if (!route) throw new Error(`未知路由键:${routeKey}`);
const normalizedParams = snapshotDataRecord(`${routeKey} 导航参数`, params);
const normalizedParams = copyDataRecord(`${routeKey} 导航参数`, params);
const allowed = new Set([...route.requiredParams, ...route.optionalParams]);
for (const name of Object.keys(normalizedParams)) {
@@ -136,15 +112,7 @@ const getPageParams = (page, route) => {
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;
if (hasOwn(candidate, name)) params[name] = candidate[name];
}
}
return params;
@@ -276,9 +244,9 @@ const asNavigationPromise = (execute) => {
const validateNavigationResult = (routeKey, result) => {
const route = getRoute(routeKey);
if (!route) throw new Error(`未知路由键:${routeKey}`);
const normalizedResult = snapshotDataRecord(`${routeKey} 导航结果`, result);
const normalizedResult = copyDataRecord(`${routeKey} 导航结果`, result);
const fields = Reflect.ownKeys(normalizedResult);
const fields = Object.keys(normalizedResult);
const allowedFields = new Set(["operation", "entityId", "refresh"]);
for (const field of fields) {
if (typeof field !== "string" || !allowedFields.has(field)) {
@@ -411,7 +379,7 @@ const activateExistingSinglePage = (routeKey, params, url) => {
const targetIndex = targetIndexes[0] ?? -1;
if (targetIndex < 0) return pushPage(url);
// T03 是当前唯一 single 页面。复用实例前必须锁定家谱上下文;若跨家谱强行
// 成员档案是当前唯一 single 页面。复用实例前必须锁定家谱上下文;若跨家谱强行
// 复用,成员轨迹和一次性结果都会串到错误领域,因此明确失败而不是猜测回退。
const existingParams = validateRouteParams(
routeKey,
@@ -424,7 +392,7 @@ const activateExistingSinglePage = (routeKey, params, url) => {
throw error;
}
const result = validateNavigationResult(routeKey, {
const navigationResult = validateNavigationResult(routeKey, {
operation: "member-open-requested",
entityId: params.personId,
refresh: false,
@@ -436,7 +404,7 @@ const activateExistingSinglePage = (routeKey, params, url) => {
stack[targetIndex],
stack[stack.length - 1],
existingParams,
result,
navigationResult,
transitionKey,
(callbacks) => uni.navigateBack({ delta, ...callbacks }),
);
@@ -459,6 +427,57 @@ export const openPage = (routeKey, params = {}, sourceKey = "") =>
return pushPage(url);
});
const confirmExternalSiteContentTarget = (targetUrl) => new Promise((resolve, reject) => {
const host = targetUrl.match(/^https:\/\/([^/?#]+)/)?.[1] || "外部网站";
uni.showModal({
title: "即将离开应用",
content: `将打开外部网站 ${host},请确认链接来源可信。`,
confirmText: "继续访问",
cancelText: "取消",
success: ({ confirm }) => resolve(confirm === true),
fail: reject,
});
});
export const openSiteContentTarget = async (targetUrl, onExternalFailure = null) => {
if (
typeof targetUrl !== "string" ||
targetUrl.trim() !== targetUrl ||
!(
/^\/(?!\/)[^\s]*$/.test(targetUrl) ||
/^https:\/\/[^\s/?#]+(?:[/?#][^\s]*)?$/.test(targetUrl)
)
) {
return Promise.reject(new TypeError("站点内容跳转地址无效"));
}
if (onExternalFailure !== null && typeof onExternalFailure !== "function") {
return Promise.reject(new TypeError("外部链接失败回调必须是函数"));
}
if (targetUrl.startsWith("/")) {
return runUniNavigation(`site-content:${targetUrl}`, (callbacks) =>
uni.navigateTo({ url: targetUrl, ...callbacks }));
}
if (!await confirmExternalSiteContentTarget(targetUrl)) return false;
if (typeof plus !== "undefined" && typeof plus.runtime?.openURL === "function") {
return new Promise((resolve, reject) => {
try {
plus.runtime.openURL(targetUrl, onExternalFailure || (() => {}));
resolve(true);
} catch (error) {
reject(error);
}
});
}
if (typeof window !== "undefined" && typeof window.location?.assign === "function") {
window.location.assign(targetUrl);
return Promise.resolve(true);
}
return Promise.reject(new Error("当前运行环境不支持打开外部链接"));
};
export const goRoot = (routeKey, params = {}) =>
asNavigationPromise(() => {
if (!ROOT_ROUTE_KEYS.includes(routeKey)) {
@@ -487,7 +506,7 @@ export const openNoticeTarget = (targetType, params, sourceKey = "N02") =>
}
const target = NOTICE_TARGETS[targetType];
const normalizedParams = snapshotDataRecord("通知目标参数", params);
const normalizedParams = copyDataRecord("通知目标参数", params);
const expectedParams = new Set(target.params);
for (const name of Object.keys(normalizedParams)) {
if (!expectedParams.has(name)) {
@@ -793,7 +812,7 @@ const resolveBackActionFromContext = ({
};
const validateBackContext = (context) => {
const normalizedContext = snapshotDataRecord("返回守卫上下文", context);
const normalizedContext = copyDataRecord("返回守卫上下文", context);
for (const flag of ["transientOpen", "internalTrail", "dirty", "submitting"]) {
if (hasOwn(normalizedContext, flag) && typeof normalizedContext[flag] !== "boolean") {
throw new TypeError(`返回守卫状态 ${flag} 必须是布尔值`);
@@ -815,9 +834,6 @@ export const handleBackPress = (event, requestBack) => {
return true;
};
export const resolveBackAction = (context = {}) =>
resolveBackActionFromContext(validateBackContext(context));
export const runBackGuard = async (context = {}) => {
const normalizedContext = validateBackContext(context);
const action = resolveBackActionFromContext(normalizedContext);
@@ -1,4 +1,4 @@
// 本文件是 52 个活动页面导航语义的唯一运行时所有者。页面只能使用路由键,
// 本文件是活动页面导航语义的唯一运行时所有者。页面只能使用路由键,
// 不得自行复制路径、父页、允许来源、参数或流程结果规则。
const defineRoute = (route) =>
Object.freeze({
@@ -16,56 +16,59 @@ const defineNoticeTarget = (routeKey, params) =>
export const NOTICE_TARGETS = Object.freeze({
GENEALOGY_REVIEW: defineNoticeTarget("G10", ["genealogyId"]),
GENEALOGY_HOME: defineNoticeTarget("G01", ["genealogyId"]),
FAMILY_FEED: defineNoticeTarget("F03", ["genealogyId", "feedId"]),
MEMO_REMINDER: defineNoticeTarget("R10", ["genealogyId", "memoId"]),
CEREMONY_INVITE: defineNoticeTarget("M11", []),
});
export const ROUTES = Object.freeze({
A01: defineRoute({
path: "/pages/auth/a01-entry",
path: "/pages/auth/sign-in",
kind: "auth-root",
parent: null,
resultOperations: ["password-reset"],
}),
A04: defineRoute({
path: "/pages/auth/a04-register",
path: "/pages/auth/register",
kind: "page",
parent: "A01",
allowedSources: ["A01"],
}),
A05: defineRoute({
path: "/pages/auth/a05-reset-password",
path: "/pages/auth/reset-password",
kind: "page",
parent: "A01",
allowedSources: ["A01"],
}),
G01: defineRoute({
path: "/pages/genealogy/g01-my-genealogies",
path: "/pages/genealogy/my-genealogies",
kind: "root",
parent: null,
optionalParams: ["genealogyId"],
resultOperations: ["genealogy-created"],
}),
G03: defineRoute({
path: "/pages/genealogy/g03-create-genealogy",
path: "/pages/genealogy/create",
kind: "flow",
parent: "G01",
allowedSources: ["G01"],
}),
G05: defineRoute({
path: "/pages/genealogy/g05-genealogy-overview",
path: "/pages/genealogy/overview",
kind: "page",
parent: "G01",
requiredParams: ["genealogyId"],
allowedSources: ["G01", "G03", "G06", "G09"],
}),
G06: defineRoute({
path: "/pages/genealogy/g06-search-genealogies",
path: "/pages/genealogy/search",
kind: "page",
parent: "G01",
optionalParams: ["mode"],
allowedSources: ["G01", "G03", "G09"],
}),
G08: defineRoute({
path: "/pages/genealogy/g08-join-application",
path: "/pages/genealogy/join-application",
kind: "flow",
parent: "G06",
requiredParams: ["genealogyId"],
@@ -73,35 +76,42 @@ export const ROUTES = Object.freeze({
allowedSources: ["G01", "G05", "G06", "G09"],
}),
G09: defineRoute({
path: "/pages/genealogy/g09-my-applications",
path: "/pages/genealogy/my-applications",
kind: "page",
parent: "G01",
optionalParams: ["status"],
allowedSources: ["G01", "G05", "G06", "G08"],
}),
G10: defineRoute({
path: "/pages/genealogy/g10-application-review",
path: "/pages/genealogy/application-review",
kind: "page",
parent: "G01",
requiredParams: ["genealogyId"],
allowedSources: ["G01", "G05", "N01", "N02"],
}),
G11: defineRoute({
path: "/pages/genealogy/g11-genealogy-settings",
path: "/pages/genealogy/settings",
kind: "flow",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["G05"],
}),
G12: defineRoute({
path: "/pages/genealogy/g12-generation-poems",
path: "/pages/genealogy/generation-poems",
kind: "flow",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["G01", "G05"],
}),
G13: defineRoute({
path: "/pages/genealogy/members",
kind: "page",
parent: "G05",
requiredParams: ["genealogyId"],
allowedSources: ["G05"],
}),
T01: defineRoute({
path: "/pages/tree/t01-tree-overview",
path: "/pages/tree/overview",
kind: "page",
parent: "G05",
requiredParams: ["genealogyId"],
@@ -109,7 +119,7 @@ export const ROUTES = Object.freeze({
allowedSources: ["G01", "G05", "T02", "T04", "T06", "T07"],
}),
T02: defineRoute({
path: "/pages/tree/t02-pedigree-overview",
path: "/pages/tree/pedigree",
kind: "page",
parent: "G05",
requiredParams: ["genealogyId"],
@@ -117,7 +127,7 @@ export const ROUTES = Object.freeze({
allowedSources: ["G01", "G05", "T01"],
}),
T03: defineRoute({
path: "/pages/tree/t03-member-profile",
path: "/pages/tree/member-profile",
kind: "single",
parent: "T01",
parentParamMap: { selectedId: "personId" },
@@ -126,7 +136,7 @@ export const ROUTES = Object.freeze({
resultOperations: ["member-open-requested", "member-updated"],
}),
T04: defineRoute({
path: "/pages/tree/t04-add-relative",
path: "/pages/tree/add-relative",
kind: "flow",
parent: "T01",
parentParamMap: { selectedId: "personId" },
@@ -135,14 +145,14 @@ export const ROUTES = Object.freeze({
allowedSources: ["T01", "T02"],
}),
T05: defineRoute({
path: "/pages/tree/t05-edit-member",
path: "/pages/tree/edit-member",
kind: "flow",
parent: "T03",
requiredParams: ["genealogyId", "personId"],
allowedSources: ["T01", "T02", "T03"],
}),
T06: defineRoute({
path: "/pages/tree/t06-edit-relationship",
path: "/pages/tree/member-rank",
kind: "flow",
parent: "T01",
parentParamMap: { selectedId: "personId" },
@@ -151,55 +161,56 @@ export const ROUTES = Object.freeze({
allowedSources: ["T01", "T02"],
}),
T07: defineRoute({
path: "/pages/tree/t07-member-directory",
path: "/pages/tree/member-directory",
kind: "page",
parent: "T01",
requiredParams: ["genealogyId"],
allowedSources: ["T01", "T02"],
}),
T08: defineRoute({
path: "/pages/tree/t08-member-states",
path: "/pages/tree/member-states",
kind: "page",
parent: "T03",
requiredParams: ["genealogyId", "personId"],
allowedSources: ["T03"],
}),
F01: defineRoute({
path: "/pages/family/f01-family-feed",
path: "/pages/family/feed",
kind: "root",
parent: null,
optionalParams: ["genealogyId"],
}),
F02: defineRoute({
path: "/pages/family/f02-publish-feed",
path: "/pages/family/feed-editor",
kind: "flow",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
requiredParams: ["genealogyId", "mode"],
optionalParams: ["feedId"],
allowedSources: ["F01", "F03"],
}),
F03: defineRoute({
path: "/pages/family/f03-feed-detail",
path: "/pages/family/feed-detail",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId", "feedId"],
allowedSources: ["F01"],
allowedSources: ["F01", "F02", "N02"],
}),
F04: defineRoute({
path: "/pages/family/f04-article-list",
path: "/pages/family/articles",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
F05: defineRoute({
path: "/pages/family/f05-article-detail",
path: "/pages/family/article-detail",
kind: "page",
parent: "F04",
requiredParams: ["genealogyId", "articleId"],
allowedSources: ["F04"],
}),
F06: defineRoute({
path: "/pages/family/f06-article-editor",
path: "/pages/family/article-editor",
kind: "flow",
parent: "F04",
requiredParams: ["genealogyId", "mode"],
@@ -207,42 +218,42 @@ export const ROUTES = Object.freeze({
allowedSources: ["F04", "F05"],
}),
F07: defineRoute({
path: "/pages/family/f07-album-list",
path: "/pages/family/albums",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
F08: defineRoute({
path: "/pages/family/f08-album-detail",
path: "/pages/family/album-detail",
kind: "page",
parent: "F07",
requiredParams: ["genealogyId", "albumId"],
allowedSources: ["F07"],
}),
F09: defineRoute({
path: "/pages/family/f09-media-upload",
path: "/pages/family/add-photo",
kind: "flow",
parent: "F08",
requiredParams: ["genealogyId", "albumId"],
allowedSources: ["F08"],
}),
F10: defineRoute({
path: "/pages/family/f10-video-list",
path: "/pages/family/videos",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
R01: defineRoute({
path: "/pages/records/r01-people-list",
path: "/pages/records/people",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
R02: defineRoute({
path: "/pages/records/r02-person-detail",
path: "/pages/records/person-detail",
kind: "flow",
parent: "R01",
requiredParams: ["genealogyId", "mode"],
@@ -250,14 +261,14 @@ export const ROUTES = Object.freeze({
allowedSources: ["R01"],
}),
R03: defineRoute({
path: "/pages/records/r03-gift-list",
path: "/pages/records/relative-records",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
R04: defineRoute({
path: "/pages/records/r04-gift-editor",
path: "/pages/records/relative-record-editor",
kind: "flow",
parent: "R03",
requiredParams: ["genealogyId", "mode"],
@@ -265,21 +276,21 @@ export const ROUTES = Object.freeze({
allowedSources: ["R03"],
}),
R05: defineRoute({
path: "/pages/records/r05-ritual-list",
path: "/pages/records/ceremonies",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
R06: defineRoute({
path: "/pages/records/r06-ritual-detail",
path: "/pages/records/ceremony-detail",
kind: "page",
parent: "R05",
requiredParams: ["genealogyId", "ceremonyId"],
allowedSources: ["R05"],
}),
R07: defineRoute({
path: "/pages/records/r07-ritual-editor",
path: "/pages/records/ceremony-editor",
kind: "flow",
parent: "R05",
requiredParams: ["genealogyId", "mode"],
@@ -287,106 +298,126 @@ export const ROUTES = Object.freeze({
allowedSources: ["R05", "R06"],
}),
R08: defineRoute({
path: "/pages/records/r08-growth-journal",
path: "/pages/records/growth-journal",
kind: "page",
parent: "R02",
requiredParams: ["genealogyId", "personId"],
allowedSources: ["R02", "T03"],
}),
R09: defineRoute({
path: "/pages/records/r09-life-events",
path: "/pages/records/life-events",
kind: "page",
parent: "R02",
requiredParams: ["genealogyId", "personId"],
allowedSources: ["R02", "T03"],
}),
R10: defineRoute({
path: "/pages/records/r10-memo-list",
path: "/pages/records/memos",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
optionalParams: ["memoId"],
allowedSources: ["F01", "N02"],
}),
R11: defineRoute({
path: "/pages/records/r11-merit-records",
path: "/pages/records/merit-records",
kind: "page",
parent: "F01",
requiredParams: ["genealogyId"],
allowedSources: ["F01"],
}),
N01: defineRoute({
path: "/pages/notification/n01-message-center",
path: "/pages/notification/message-center",
kind: "page",
parent: "G01",
optionalParams: ["genealogyId"],
allowedSources: ["G01", "M01"],
}),
N02: defineRoute({
path: "/pages/notification/n02-message-detail",
path: "/pages/notification/message-detail",
kind: "page",
parent: "N01",
requiredParams: ["id"],
allowedSources: ["N01"],
}),
M01: defineRoute({
path: "/pages/profile/m01-profile-home",
path: "/pages/profile/home",
kind: "root",
parent: null,
}),
M02: defineRoute({
path: "/pages/profile/m02-edit-profile",
path: "/pages/profile/edit-profile",
kind: "flow",
parent: "M01",
allowedSources: ["M01"],
}),
M03: defineRoute({
path: "/pages/profile/m03-security-settings",
path: "/pages/profile/security",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
}),
M04: defineRoute({
path: "/pages/profile/m04-change-password",
path: "/pages/profile/change-password",
kind: "flow",
parent: "M03",
allowedSources: ["M03"],
}),
M05: defineRoute({
path: "/pages/profile/m05-change-phone",
path: "/pages/profile/change-phone",
kind: "flow",
parent: "M03",
allowedSources: ["M03"],
}),
M06: defineRoute({
path: "/pages/profile/m06-help-center",
path: "/pages/profile/help",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
}),
M07: defineRoute({
path: "/pages/profile/m07-feedback",
path: "/pages/profile/feedback",
kind: "flow",
parent: "M06",
allowedSources: ["M01", "M06"],
}),
M08: defineRoute({
path: "/pages/profile/m08-promotion",
path: "/pages/profile/promotions",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
}),
M09: defineRoute({
path: "/pages/profile/m09-vip-orders",
path: "/pages/profile/vip",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
}),
M10: defineRoute({
path: "/pages/profile/m10-about-settings",
path: "/pages/profile/settings",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
}),
M11: defineRoute({
path: "/pages/profile/ceremony-invitations",
kind: "page",
parent: "M01",
allowedSources: ["M01", "N02"],
}),
M12: defineRoute({
path: "/pages/profile/earnings",
kind: "page",
parent: "M01",
allowedSources: ["M01"],
}),
M13: defineRoute({
path: "/pages/profile/compliance-document",
kind: "page",
parent: "M10",
requiredParams: ["documentKey"],
allowedSources: ["M10"],
}),
});
export const ROOT_ROUTE_KEYS = Object.freeze(["A01", "G01", "F01", "M01"]);
+18
View File
@@ -0,0 +1,18 @@
import { isWriteOutcomeUnknown } from '@/utils/request-outcome.js'
export const createNonIdempotentWriteGuard = () => {
let uncertainPayloadSnapshot = ''
return {
begin(payload) {
const payloadSnapshot = JSON.stringify(payload)
return payloadSnapshot === uncertainPayloadSnapshot ? null : payloadSnapshot
},
recordFailure(payloadSnapshot, error) {
if (!isWriteOutcomeUnknown(error)) return false
uncertainPayloadSnapshot = payloadSnapshot
return true
}
}
}
+52
View File
@@ -0,0 +1,52 @@
const markdownHeading = /^(#{1,6})\s+(.+)$/;
const orderedListItem = /^(\d+)[.、]\s*(.+)$/;
const unorderedListItem = /^[-*]\s+(.+)$/;
export const formatComplianceContent = (value, documentTitle = "") => {
if (typeof value !== "string" || !value.trim()) return [];
const blocks = value
.replace(/\r\n?/g, "\n")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const heading = line.match(markdownHeading);
if (heading) {
return {
type: heading[1].length === 1 ? "title" : "heading",
text: heading[2].trim(),
};
}
const ordered = line.match(orderedListItem);
if (ordered) {
return {
type: "list-item",
marker: `${ordered[1]}.`,
text: ordered[2].trim(),
};
}
const unordered = line.match(unorderedListItem);
if (unordered) {
return { type: "list-item", marker: "•", text: unordered[1].trim() };
}
return { type: "paragraph", text: line };
});
const duplicateTitle =
blocks[0]?.type === "title" &&
blocks[0].text === String(documentTitle || "").trim();
if (!duplicateTitle) return blocks;
let contentBlocks = blocks.slice(1);
while (
contentBlocks[0]?.type === "paragraph" &&
/^(?:版本号|版本|发布日期|生效日期)\s*[::]/.test(contentBlocks[0].text)
) {
contentBlocks = contentBlocks.slice(1);
}
return contentBlocks;
};
+14
View File
@@ -0,0 +1,14 @@
export const parseMoneyToCents = (amount) => {
const amountParts = String(amount || "").match(/^(\d+)(?:\.(\d{1,2}))?$/);
if (!amountParts) return null;
return (
BigInt(amountParts[1]) * 100n +
BigInt((amountParts[2] || "").padEnd(2, "0"))
);
};
export const formatSignedMoney = (amount) => {
const normalizedAmount = String(amount);
const sign = normalizedAmount.startsWith("-") ? "-" : "+";
return `${sign}¥${normalizedAmount.replace(/^-/, "")}`;
};
+144
View File
@@ -0,0 +1,144 @@
import { isWriteOutcomeUnknown } from '@/utils/request-outcome.js'
const messages = {
submitted:
"反馈已提交。以下内容为本次提交记录;修改任一项后可提交新反馈。",
submittedWithEdits: "上一份反馈已提交,当前修改尚未提交。",
uncertain:
"暂时无法确认是否提交成功,请不要重复提交相同内容。你可以修改内容后再提交一份反馈。",
uncertainWithEdits: "上一份反馈暂未确认,当前修改尚未提交。",
};
const normalizeFeedback = (form) => ({
feedbackType: String(form?.feedbackType || "").trim(),
feedbackContent: String(form?.feedbackContent || "").trim(),
contactInfo: String(form?.contactInfo || "").trim(),
});
// 字段顺序固定的快照既用于脏数据判断,也用于阻止非幂等 POST 重复提交。
// 不能改成对象引用比较,否则 Vue 表单原地修改时无法识别同一份内容。
const snapshotOf = (form) => JSON.stringify(normalizeFeedback(form));
const errorMessage = (error) => {
if (error?.code === "WRITE_UNAVAILABLE") {
return "当前为本地预览,反馈没有发出。";
}
if (error?.httpStatus === 401) {
return "登录状态已失效,请重新登录后再提交。";
}
return "提交未完成,请稍后再试。";
};
const submitLabelFor = (phase) => {
if (phase === "submitting") return "正在提交";
if (phase === "success") return "反馈已提交";
if (phase === "uncertain") return "暂未确认";
if (phase === "error") return "重新提交";
return "提交反馈";
};
export const createFeedbackSubmissionSession = (initialForm) => {
let baselineSnapshot = snapshotOf(initialForm);
let lastSubmittedSnapshot = "";
let lastUncertainSnapshot = "";
let phase = "ready";
let message = "";
let tone = "";
const view = (currentForm) => {
const currentSnapshot = snapshotOf(currentForm);
const isRepeatedSubmission =
currentSnapshot === lastSubmittedSnapshot ||
currentSnapshot === lastUncertainSnapshot;
return {
phase,
message,
tone,
isDirty: currentSnapshot !== baselineSnapshot,
isSubmitDisabled: phase === "submitting" || isRepeatedSubmission,
submitLabel: submitLabelFor(phase),
};
};
const begin = (currentForm) => {
const currentSnapshot = snapshotOf(currentForm);
if (
phase === "submitting" ||
currentSnapshot === lastSubmittedSnapshot ||
currentSnapshot === lastUncertainSnapshot
) {
return null;
}
phase = "submitting";
message = "";
tone = "";
return {
snapshot: currentSnapshot,
payload: normalizeFeedback(currentForm),
};
};
const succeed = (submission, currentForm) => {
baselineSnapshot = submission.snapshot;
lastSubmittedSnapshot = submission.snapshot;
tone = "success";
if (snapshotOf(currentForm) === submission.snapshot) {
phase = "success";
message = messages.submitted;
} else {
phase = "ready";
message = messages.submittedWithEdits;
}
};
const fail = (submission, error, currentForm) => {
if (!isWriteOutcomeUnknown(error)) {
phase = "error";
message = errorMessage(error);
tone = "error";
return;
}
// 超时或异常 2xx 可能已经被后端写入。记录原快照并禁用原样重试,
// 避免用户在网络恢复后生成两条内容完全相同的反馈。
lastUncertainSnapshot = submission.snapshot;
tone = "uncertain";
if (snapshotOf(currentForm) === submission.snapshot) {
phase = "uncertain";
message = messages.uncertain;
} else {
phase = "ready";
message = messages.uncertainWithEdits;
}
};
const reconcile = (currentForm) => {
if (phase === "submitting") return;
const currentSnapshot = snapshotOf(currentForm);
if (lastSubmittedSnapshot && currentSnapshot === lastSubmittedSnapshot) {
phase = "success";
message = messages.submitted;
tone = "success";
return;
}
if (lastUncertainSnapshot && currentSnapshot === lastUncertainSnapshot) {
phase = "uncertain";
message = messages.uncertain;
tone = "uncertain";
return;
}
phase = "ready";
if (lastUncertainSnapshot) {
message = messages.uncertainWithEdits;
tone = "uncertain";
} else if (lastSubmittedSnapshot) {
message = messages.submittedWithEdits;
tone = "success";
} else {
message = "";
tone = "";
}
};
return { begin, succeed, fail, reconcile, view };
};
+18
View File
@@ -0,0 +1,18 @@
export const isWriteOutcomeUnknown = (error) => {
if (!error || typeof error !== 'object') return false
if (
error.code === 'REQUEST_TIMEOUT' ||
error.code === 'NETWORK_ERROR' ||
error.code === 'RESPONSE_INVALID' ||
error.code === 'REQUEST_CANCELLED'
) {
return true
}
if (error.code !== 'HTTP_ERROR' && error.code !== 'BUSINESS_ERROR') return false
const status = Number(
error.code === 'HTTP_ERROR' ? error.httpStatus : error.businessCode
)
if (!Number.isInteger(status)) return true
return !(status >= 400 && status < 500 && status !== 408)
}
+34
View File
@@ -0,0 +1,34 @@
const configuredMode = import.meta.env.VITE_JIAPU_RUNTIME_MODE || 'remote'
const configuredBaseUrl = import.meta.env.VITE_JIAPU_API_BASE_URL || 'https://backend-api.ddxcjp.cn'
const configuredClientId = import.meta.env.VITE_JIAPU_CLIENT_ID || '428a8310cd442757ae699df5d894f051'
const configuredTenantId = import.meta.env.VITE_JIAPU_TENANT_ID || '000000'
if (!['mock', 'remote'].includes(configuredMode)) {
throw new Error('VITE_JIAPU_RUNTIME_MODE 只允许 mock 或 remote')
}
if (configuredMode === 'remote' && !/^https:\/\/[^\s/]+(?:\/[^\s]*)?$/.test(configuredBaseUrl)) {
throw new Error('VITE_JIAPU_API_BASE_URL 必须是 HTTPS 地址')
}
if (configuredBaseUrl.endsWith('/')) {
throw new Error('VITE_JIAPU_API_BASE_URL 不能以斜杠结尾')
}
if (!configuredClientId || !configuredTenantId) {
throw new Error('远程服务的 clientId 和 tenantId 不能为空')
}
// 业务模块只消费这一个不可变配置;不同构建环境通过 VITE_JIAPU_* 覆盖。
export const runtimeConfig = Object.freeze({
mode: configuredMode,
baseUrl: configuredMode === 'remote' ? configuredBaseUrl : '',
clientId: configuredClientId,
tenantId: configuredTenantId
})
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')
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { genealogyContext } from "./genealogy-context.js";
import { genealogyContext } from "./genealogy/context.js";
const TOKEN_KEY = 'jiapu_token'
+323
View File
@@ -0,0 +1,323 @@
const GRID_UNIT = 5;
const NODE_HALF_WIDTH = 80;
const NODE_HALF_HEIGHT = 112;
const FAMILY_LINK_OFFSET = 18;
const MEMBER_GAP = 178;
const GENERATION_GAP = 296;
const snapToGrid = (coordinate) =>
Math.ceil(coordinate / GRID_UNIT) * GRID_UNIT;
const compareMembers = (leftMember, rightMember) =>
Number(leftMember.generation) - Number(rightMember.generation) ||
String(leftMember.id).localeCompare(String(rightMember.id));
const positionMembers = (members) => {
const memberById = new Map(
members.map((member) => [String(member.id), member]),
);
const spousesByPersonId = new Map();
members.forEach((member) => {
const partnerId = String(member.spouseOf || "");
if (partnerId && memberById.has(partnerId)) {
spousesByPersonId.set(partnerId, member);
}
});
const primaryMembers = members
.filter((member) => !member.spouseOf)
.sort(compareMembers);
const primaryById = new Map(
primaryMembers.map((member) => [String(member.id), member]),
);
const childrenByParent = new Map();
primaryMembers.forEach((member) => {
const parentId = String(member.parentId || "");
if (!parentId || !primaryById.has(parentId)) return;
const children = childrenByParent.get(parentId) || [];
children.push(member);
childrenByParent.set(parentId, children);
});
childrenByParent.forEach((children) => children.sort(compareMembers));
// 每个家庭独占一段连续横向空间,父辈始终位于该空间中央。
// 不能按世代逐行居中,否则后代较多的支系会被拉到另一支系下方。
const subtreeWidthById = new Map();
const measureSubtree = (member, ancestorIds = new Set()) => {
const memberId = String(member.id);
if (subtreeWidthById.has(memberId)) {
return subtreeWidthById.get(memberId);
}
if (ancestorIds.has(memberId)) return 1;
const nextAncestorIds = new Set(ancestorIds);
nextAncestorIds.add(memberId);
const children = childrenByParent.get(memberId) || [];
const descendantsWidth = children.reduce(
(totalWidth, child) =>
totalWidth + measureSubtree(child, nextAncestorIds),
0,
);
const ownWidth = spousesByPersonId.has(memberId) ? 2 : 1;
const subtreeWidth = Math.max(ownWidth, descendantsWidth || 1);
subtreeWidthById.set(memberId, subtreeWidth);
return subtreeWidth;
};
const rootMembers = primaryMembers.filter(
(member) => !primaryById.has(String(member.parentId || "")),
);
rootMembers.forEach((rootMember) => measureSubtree(rootMember));
const occupiedWidth = rootMembers.reduce(
(totalWidth, rootMember) =>
totalWidth + (subtreeWidthById.get(String(rootMember.id)) || 1),
0,
);
const generations = Array.from(
new Set(primaryMembers.map((member) => Number(member.generation))),
).sort((leftGeneration, rightGeneration) =>
leftGeneration - rightGeneration,
);
const generationIndexByNumber = new Map(
generations.map((generation, index) => [generation, index]),
);
const positionedMembers = [];
const positionedMemberIds = new Set();
const placeSubtree = (
member,
startUnit,
subtreeWidth,
ancestorIds = new Set(),
) => {
const memberId = String(member.id);
if (ancestorIds.has(memberId)) return;
const nextAncestorIds = new Set(ancestorIds);
nextAncestorIds.add(memberId);
const spouse = spousesByPersonId.get(memberId);
const centerUnit = startUnit + subtreeWidth / 2;
const memberUnitX = spouse ? centerUnit - 0.5 : centerUnit;
const y = snapToGrid(
200 +
(generationIndexByNumber.get(Number(member.generation)) || 0) *
GENERATION_GAP,
);
positionedMembers.push({
...member,
x: snapToGrid(80 + memberUnitX * MEMBER_GAP),
y,
});
positionedMemberIds.add(memberId);
if (spouse) {
positionedMembers.push({
...spouse,
x: snapToGrid(80 + (memberUnitX + 1) * MEMBER_GAP),
y,
});
positionedMemberIds.add(String(spouse.id));
}
const children = childrenByParent.get(memberId) || [];
const childrenWidth = children.reduce(
(totalWidth, child) =>
totalWidth + (subtreeWidthById.get(String(child.id)) || 1),
0,
);
let childStartUnit = startUnit + (subtreeWidth - childrenWidth) / 2;
children.forEach((child) => {
const childWidth = subtreeWidthById.get(String(child.id)) || 1;
placeSubtree(child, childStartUnit, childWidth, nextAncestorIds);
childStartUnit += childWidth;
});
};
let rootStartUnit = 0;
rootMembers.forEach((rootMember) => {
const rootWidth = subtreeWidthById.get(String(rootMember.id)) || 1;
placeSubtree(rootMember, rootStartUnit, rootWidth);
rootStartUnit += rootWidth;
});
members
.filter((member) => !positionedMemberIds.has(String(member.id)))
.forEach((member, index) => {
positionedMembers.push({
...member,
x: snapToGrid(80 + (occupiedWidth + index + 0.5) * MEMBER_GAP),
y: snapToGrid(
200 +
(generationIndexByNumber.get(Number(member.generation)) || 0) *
GENERATION_GAP,
),
});
});
return positionedMembers;
};
const createMetrics = (positionedMembers) => {
const maxX = Math.max(0, ...positionedMembers.map((member) => member.x));
const maxY = Math.max(0, ...positionedMembers.map((member) => member.y));
const width = Math.max(660, snapToGrid(maxX + NODE_HALF_WIDTH + 50));
const height = Math.max(640, snapToGrid(maxY + 250));
return {
width,
height,
gridUnit: GRID_UNIT,
columns: width / GRID_UNIT,
rows: height / GRID_UNIT,
};
};
const createGenerationRows = (positionedMembers) => {
const membersByGeneration = new Map();
positionedMembers.forEach((member) => {
const generationMembers = membersByGeneration.get(member.generation) || [];
generationMembers.push(member);
membersByGeneration.set(member.generation, generationMembers);
});
return Array.from(membersByGeneration.entries())
.sort(([leftGeneration], [rightGeneration]) =>
leftGeneration - rightGeneration,
)
.map(([generation, generationMembers]) => {
const y = Math.min(...generationMembers.map((member) => member.y));
return {
generation,
label: `${generation}`,
summary: `${generationMembers.length} 位成员`,
y,
bandStyle: {
gridRow: `${Math.max(1, Math.round((y - NODE_HALF_HEIGHT) / GRID_UNIT) + 1)} / span 21`,
},
};
});
};
const createConnectors = (positionedMembers) => {
const memberById = new Map(
positionedMembers.map((member) => [String(member.id), member]),
);
const spouseByPersonId = new Map();
positionedMembers.forEach((member) => {
const partnerId = String(member.spouseOf || "");
if (partnerId && memberById.has(partnerId)) {
spouseByPersonId.set(partnerId, member);
}
});
const childrenByParent = new Map();
positionedMembers.forEach((member) => {
const parentId = String(member.parentId || "");
if (member.spouseOf || !parentId || !memberById.has(parentId)) return;
const children = childrenByParent.get(parentId) || [];
children.push(member);
childrenByParent.set(parentId, children);
});
const verticalStyle = (x, top, bottom) => ({
gridColumn: `${Math.round(x / GRID_UNIT) + 1} / span 1`,
gridRow: `${Math.round(top / GRID_UNIT) + 1} / ${Math.round(bottom / GRID_UNIT) + 1}`,
});
const horizontalStyle = (left, right, y) => ({
gridColumn: `${Math.round(left / GRID_UNIT) + 1} / ${Math.round(right / GRID_UNIT) + 2}`,
gridRow: `${Math.round(y / GRID_UNIT) + 1} / span 1`,
});
const connectors = [];
childrenByParent.forEach((children, parentId) => {
const parent = memberById.get(parentId);
const spouse = spouseByPersonId.get(parentId);
const parentAnchorX = spouse
? snapToGrid((parent.x + spouse.x) / 2)
: parent.x;
const parentBottom = parent.y + NODE_HALF_HEIGHT;
const parentAnchorY = spouse
? snapToGrid(parentBottom + FAMILY_LINK_OFFSET)
: parentBottom;
const childTop = Math.min(
...children.map((child) => child.y - NODE_HALF_HEIGHT),
);
const branchY = snapToGrid((parentAnchorY + childTop) / 2);
const minChildX = Math.min(...children.map((child) => child.x));
const maxChildX = Math.max(...children.map((child) => child.x));
if (spouse) {
connectors.push({
id: `${parentId}-${spouse.id}-family-parent`,
kind: "family",
style: verticalStyle(parent.x, parentBottom, parentAnchorY),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-spouse`,
kind: "family",
style: verticalStyle(
spouse.x,
spouse.y + NODE_HALF_HEIGHT,
parentAnchorY,
),
});
connectors.push({
id: `${parentId}-${spouse.id}-family-bridge`,
kind: "family",
style: horizontalStyle(
Math.min(parent.x, spouse.x),
Math.max(parent.x, spouse.x),
parentAnchorY,
),
});
}
connectors.push({
id: `${parentId}-trunk`,
style: verticalStyle(parentAnchorX, parentAnchorY, branchY),
});
connectors.push({
id: `${parentId}-branch`,
style: horizontalStyle(minChildX, maxChildX, branchY),
});
children.forEach((child) => {
connectors.push({
id: `${parentId}-${child.id}`,
style: verticalStyle(child.x, branchY, child.y - NODE_HALF_HEIGHT),
});
});
});
positionedMembers.forEach((member) => {
if (!member.spouseOf) return;
const partner = memberById.get(String(member.spouseOf));
if (!partner || partner.generation !== member.generation) return;
if (childrenByParent.has(String(partner.id))) return;
const left = Math.min(member.x, partner.x) + NODE_HALF_WIDTH;
const right = Math.max(member.x, partner.x) - NODE_HALF_WIDTH;
if (right <= left) return;
connectors.push({
id: `${partner.id}-${member.id}-spouse`,
kind: "spouse",
style: horizontalStyle(left, right, member.y),
});
});
return connectors;
};
// 这是世系图布局的唯一公开入口。页面只消费坐标、尺寸、世代行和连线,
// 不需要了解支系宽度测量、循环关系保护或配偶桥接规则。
export const createTreeLayout = (members) => {
const positionedMembers = positionMembers(Array.isArray(members) ? members : []);
return {
members: positionedMembers,
metrics: createMetrics(positionedMembers),
generationRows: createGenerationRows(positionedMembers),
connectors: createConnectors(positionedMembers),
};
};
export const treeNodeGridStyle = (member) => ({
gridColumn: `${member.x / GRID_UNIT + 1}`,
gridRow: `${member.y / GRID_UNIT + 1}`,
});
+41
View File
@@ -0,0 +1,41 @@
import {
LINEAGE_PERSON_OPTIONS,
LINEAGE_RELATION_OPTIONS,
LINEAGE_RELATION_TYPE
} from '@/services/api/lineage-person-options.js'
export const memberFormOptions = LINEAGE_PERSON_OPTIONS
export const memberRelationOptions = LINEAGE_RELATION_OPTIONS
export const memberRelationTypes = LINEAGE_RELATION_TYPE
export const findMemberOptionIndex = (options, value) => Math.max(
0,
options.findIndex((option) => option.value === value)
)
export const findMemberOptionLabel = (options, value) => (
options.find((option) => option.value === value)?.label || ''
)
const pickerOptionValue = (options, event, fallback = '') => (
options[Number(event.detail.value)]?.value || fallback
)
export const updateMemberFormOption = (form, field, options, event) => {
form[field] = pickerOptionValue(options, event)
if (field === 'personStatus' && form.personStatus !== '1') {
Object.assign(form, {
deathDate: '',
deathLunar: '',
deathPlace: '',
burialPlace: ''
})
}
return form[field]
}
export const updateMemberBindingMode = (form, event) => {
form.bindingMode = pickerOptionValue(memberFormOptions.bindingMode, event, 'NONE')
if (form.bindingMode !== 'SPECIFIED') form.appUserId = ''
return form.bindingMode
}